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):

Average Hourly Distribution

- Peak hours (PT)
@@ -377,33 +392,18 @@ def get_dashboard_data(db_path=DB_PATH): let sessionSortDir = 'desc'; let hourlyTZ = 'local'; // 'local' or 'utc' -// ── Peak-hour config ─────────────────────────────────────────────────────── -// Anthropic throttles Mon–Fri 05:00–11:00 PT. We approximate as fixed UTC hours -// 12–17 (matches PDT; during PST the window shifts by 1h — accepted simplification). -const PEAK_HOURS_UTC = new Set([12, 13, 14, 15, 16, 17]); - // Local-timezone offset in hours (signed). Fractional offsets (e.g. India UTC+5:30) // are rounded to the nearest hour for bucket alignment. function localOffsetHours() { return Math.round(-new Date().getTimezoneOffset() / 60); } -// Return the UTC hour (0–23) corresponding to a displayed-hour bucket. -function displayHourToUTC(displayHour, tzMode) { - if (tzMode === 'utc') return displayHour; - return ((displayHour - localOffsetHours()) % 24 + 24) % 24; -} - // Return the displayed-hour bucket for a UTC hour. function utcHourToDisplay(utcHour, tzMode) { if (tzMode === 'utc') return utcHour; return ((utcHour + localOffsetHours()) % 24 + 24) % 24; } -function isPeakHour(displayHour, tzMode) { - return PEAK_HOURS_UTC.has(displayHourToUTC(displayHour, tzMode)); -} - function formatHourLabel(h) { return String(h).padStart(2, '0') + ':00'; } @@ -418,17 +418,7 @@ def get_dashboard_data(db_path=DB_PATH): } // ── Pricing (Anthropic API, April 2026) ──────────────────────────────────── -const PRICING = { - 'claude-opus-4-7': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-opus-4-6': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-opus-4-5': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-sonnet-4-7': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-sonnet-4-6': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-sonnet-4-5': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-haiku-4-7': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, - 'claude-haiku-4-6': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, - 'claude-haiku-4-5': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, -}; +const PRICING = /*__PRICING_JSON__*/; function isBillable(model) { if (!model) return false; @@ -555,7 +545,10 @@ def get_dashboard_data(db_path=DB_PATH): function readURLModels(allModels) { const param = new URLSearchParams(window.location.search).get('models'); - if (!param) return new Set(allModels.filter(m => isBillable(m))); + if (!param) { + const billable = allModels.filter(m => isBillable(m)); + return new Set(billable.length > 0 ? billable : allModels); + } const fromURL = new Set(param.split(',').map(s => s.trim()).filter(Boolean)); return new Set(allModels.filter(m => fromURL.has(m))); } @@ -742,7 +735,7 @@ def get_dashboard_data(db_path=DB_PATH): // Hourly aggregation (filtered by model + range, then bucketed by UTC hour) const hourlySrc = (rawData.hourly_by_model || []).filter(r => - selectedModels.has(r.model) && (!cutoff || r.day >= cutoff) + selectedModels.has(r.model) && (!start || r.day >= start) && (!end || r.day <= end) ); const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ); @@ -805,7 +798,6 @@ def get_dashboard_data(db_path=DB_PATH): avgTurns: dayCount ? byHour[h].turns / dayCount : 0, avgOutput: dayCount ? byHour[h].output / dayCount : 0, totalTurns: byHour[h].turns, - peak: isPeakHour(h, tzMode), }); } return { hours, dayCount }; @@ -820,10 +812,9 @@ def get_dashboard_data(db_path=DB_PATH): const ctx = document.getElementById('chart-hourly').getContext('2d'); if (charts.hourly) charts.hourly.destroy(); - const labels = agg.hours.map(h => (h.peak ? '⚡ ' : '') + formatHourLabel(h.hour)); + const labels = agg.hours.map(h => formatHourLabel(h.hour)); const turns = agg.hours.map(h => h.avgTurns); const output = agg.hours.map(h => h.avgOutput); - const barColors = agg.hours.map(h => h.peak ? 'rgba(248,113,113,0.8)' : TOKEN_COLORS.input); charts.hourly = new Chart(ctx, { data: { @@ -833,7 +824,7 @@ def get_dashboard_data(db_path=DB_PATH): type: 'bar', label: 'Avg turns / hour', data: turns, - backgroundColor: barColors, + backgroundColor: TOKEN_COLORS.input, yAxisID: 'y', order: 2, }, @@ -862,8 +853,7 @@ def get_dashboard_data(db_path=DB_PATH): if (!items.length) return ''; const idx = items[0].dataIndex; const h = agg.hours[idx]; - const base = formatHourLabel(h.hour) + ' ' + tzDisplayName(hourlyTZ); - return h.peak ? base + ' · Peak — Anthropic US hours' : base; + return formatHourLabel(h.hour) + ' ' + tzDisplayName(hourlyTZ); }, label: (item) => { if (item.dataset.label && item.dataset.label.indexOf('turns') !== -1) { @@ -960,8 +950,11 @@ def get_dashboard_data(db_path=DB_PATH): const costCell = isBillable(s.model) ? `${fmtCost(cost)}` : `n/a`; + const sessionCell = s.session_name + ? `${esc(s.session_name)} (${esc(s.session_id)}…)` + : `${esc(s.session_id)}…`; return ` - ${esc(s.session_id)}… + ${sessionCell} ${esc(s.project)} ${esc(s.last)} ${esc(s.duration_min)}m @@ -1141,10 +1134,10 @@ def get_dashboard_data(db_path=DB_PATH): } function exportSessionsCSV() { - const header = ['Session', 'Project', 'Last Active', 'Duration (min)', 'Model', 'Turns', 'Input', 'Output', 'Cache Read', 'Cache Creation', 'Est. Cost']; + const header = ['Session ID', 'Session Name', 'Project', 'Last Active', 'Duration (min)', 'Model', 'Turns', 'Input', 'Output', 'Cache Read', 'Cache Creation', 'Est. Cost']; const rows = lastFilteredSessions.map(s => { const cost = calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); - return [s.session_id, s.project, s.last, s.duration_min, s.model, s.turns, s.input, s.output, s.cache_read, s.cache_creation, cost.toFixed(4)]; + return [s.session_id, s.session_name || '', s.project, s.last, s.duration_min, s.model, s.turns, s.input, s.output, s.cache_read, s.cache_creation, cost.toFixed(4)]; }); downloadCSV('sessions', header, rows); } @@ -1237,18 +1230,26 @@ def get_dashboard_data(db_path=DB_PATH): """ +def render_html(): + return HTML_TEMPLATE.replace( + PRICING_JSON_PLACEHOLDER, + json.dumps(PRICING), + ).encode("utf-8") + + class DashboardHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): pass def do_GET(self): - if self.path in ("/", "/index.html"): + path = urllib.parse.urlparse(self.path).path + if path in ("/", "/index.html"): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() - self.wfile.write(HTML_TEMPLATE.encode("utf-8")) + self.wfile.write(render_html()) - elif self.path == "/api/data": + elif path == "/api/data": data = get_dashboard_data() body = json.dumps(data).encode("utf-8") self.send_response(200) @@ -1262,7 +1263,8 @@ def do_GET(self): self.end_headers() def do_POST(self): - if self.path == "/api/rescan": + path = urllib.parse.urlparse(self.path).path + if path == "/api/rescan": # Full rebuild: delete DB and rescan from scratch. # Pass DB_PATH / DEFAULT_PROJECTS_DIRS explicitly so tests that # patch the module globals are honored (scan's defaults are @@ -1273,7 +1275,6 @@ def do_POST(self): db_path.unlink() result = scanner.scan( db_path=db_path, - projects_dirs=scanner.DEFAULT_PROJECTS_DIRS, verbose=False, ) body = json.dumps(result).encode("utf-8") @@ -1290,7 +1291,7 @@ def do_POST(self): def serve(host=None, port=None): host = host or os.environ.get("HOST", "localhost") port = port or int(os.environ.get("PORT", "8080")) - server = HTTPServer((host, port), DashboardHandler) + server = ThreadingHTTPServer((host, port), DashboardHandler) print(f"Dashboard running at http://{host}:{port}") print("Press Ctrl+C to stop.") try: diff --git a/pricing.py b/pricing.py new file mode 100644 index 0000000..aa932ce --- /dev/null +++ b/pricing.py @@ -0,0 +1,32 @@ +"""Pricing data and lookup helpers for Claude model usage.""" + +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}, +} + + +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 diff --git a/scanner.py b/scanner.py index e40e100..69fab7f 100644 --- a/scanner.py +++ b/scanner.py @@ -9,6 +9,8 @@ from pathlib import Path from datetime import datetime, timezone +import cowork + PROJECTS_DIR = Path.home() / ".claude" / "projects" XCODE_PROJECTS_DIR = Path.home() / "Library" / "Developer" / "Xcode" / "CodingAssistant" / "ClaudeAgentConfig" / "projects" DB_PATH = Path.home() / ".claude" / "usage.db" @@ -51,7 +53,8 @@ def init_db(conn): total_cache_read INTEGER DEFAULT 0, total_cache_creation INTEGER DEFAULT 0, model TEXT, - turn_count INTEGER DEFAULT 0 + turn_count INTEGER DEFAULT 0, + session_name TEXT ); CREATE TABLE IF NOT EXISTS turns ( @@ -83,6 +86,11 @@ def init_db(conn): conn.execute("SELECT message_id FROM turns LIMIT 1") except sqlite3.OperationalError: conn.execute("ALTER TABLE turns ADD COLUMN message_id TEXT") + # Add session_name column if upgrading from older schema + try: + conn.execute("SELECT session_name FROM sessions LIMIT 1") + except sqlite3.OperationalError: + conn.execute("ALTER TABLE sessions ADD COLUMN session_name TEXT") # Conditional unique index: only dedup non-null message IDs conn.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_turns_message_id @@ -91,6 +99,28 @@ def init_db(conn): conn.commit() +def extract_session_name(record, current_custom, current_agent): + """Extract customTitle / agentName from a record, preferring non-empty updates. + + Claude Code logs emit `{"type":"custom-title","customTitle":"..."}` and + `{"type":"agent-name","agentName":"..."}` records when a session is renamed + via `/rename`. The last non-empty value per session wins, with customTitle + taking precedence over agentName at display time. + """ + ct = record.get("customTitle") + an = record.get("agentName") + if ct: + current_custom = ct + if an: + current_agent = an + return current_custom, current_agent + + +def resolve_session_name(custom_title, agent_name): + """Return the preferred display name: customTitle first, agentName second.""" + return custom_title or agent_name or None + + def project_name_from_cwd(cwd): """Derive a friendly project name from cwd path.""" if not cwd: @@ -125,36 +155,49 @@ def parse_jsonl_file(filepath): except json.JSONDecodeError: continue - rtype = record.get("type") - if rtype not in ("assistant", "user"): - continue - session_id = record.get("sessionId") if not session_id: continue - timestamp = record.get("timestamp", "") - cwd = record.get("cwd", "") - git_branch = record.get("gitBranch", "") - - # Update session metadata from any record + # Initialise meta on first sight of a session, regardless of record type if session_id not in session_meta: session_meta[session_id] = { "session_id": session_id, - "project_name": project_name_from_cwd(cwd), - "first_timestamp": timestamp, - "last_timestamp": timestamp, - "git_branch": git_branch, + "project_name": project_name_from_cwd(record.get("cwd", "")), + "first_timestamp": record.get("timestamp", ""), + "last_timestamp": record.get("timestamp", ""), + "git_branch": record.get("gitBranch", ""), "model": None, + "custom_title": None, + "agent_name": None, } - else: - meta = session_meta[session_id] - if timestamp and (not meta["first_timestamp"] or timestamp < meta["first_timestamp"]): - meta["first_timestamp"] = timestamp - if timestamp and (not meta["last_timestamp"] or timestamp > meta["last_timestamp"]): - meta["last_timestamp"] = timestamp - if git_branch and not meta["git_branch"]: - meta["git_branch"] = git_branch + + # Capture session_name from any record (custom-title / agent-name types + # emit it as a top-level field, but the issue notes it can appear opportunistically) + meta = session_meta[session_id] + meta["custom_title"], meta["agent_name"] = extract_session_name( + record, meta["custom_title"], meta["agent_name"] + ) + + rtype = record.get("type") + if rtype not in ("assistant", "user"): + continue + + timestamp = record.get("timestamp", "") + cwd = record.get("cwd", "") + git_branch = record.get("gitBranch", "") + + # Update session metadata from assistant/user records + if timestamp and (not meta["first_timestamp"] or timestamp < meta["first_timestamp"]): + meta["first_timestamp"] = timestamp + if timestamp and (not meta["last_timestamp"] or timestamp > meta["last_timestamp"]): + meta["last_timestamp"] = timestamp + if git_branch and not meta["git_branch"]: + meta["git_branch"] = git_branch + # Backfill project_name if the record that created meta lacked cwd + # (e.g. a custom-title record preceded the first user/assistant line) + if cwd and (not meta["project_name"] or meta["project_name"] == "unknown"): + meta["project_name"] = project_name_from_cwd(cwd) if rtype == "assistant": msg = record.get("message", {}) @@ -240,7 +283,11 @@ def aggregate_sessions(session_metas, turns): for meta in session_metas: sid = meta["session_id"] stats = session_stats[sid] - result.append({**meta, **stats}) + merged = {**meta, **stats} + merged["session_name"] = resolve_session_name( + meta.get("custom_title"), meta.get("agent_name") + ) + result.append(merged) return result @@ -258,14 +305,15 @@ def upsert_sessions(conn, sessions): INSERT INTO sessions (session_id, project_name, first_timestamp, last_timestamp, git_branch, total_input_tokens, total_output_tokens, - total_cache_read, total_cache_creation, model, turn_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + total_cache_read, total_cache_creation, model, turn_count, + session_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( s["session_id"], s["project_name"], s["first_timestamp"], s["last_timestamp"], s["git_branch"], s["total_input_tokens"], s["total_output_tokens"], s["total_cache_read"], s["total_cache_creation"], - s["model"], s["turn_count"] + s["model"], s["turn_count"], s.get("session_name") )) else: # Update: add new tokens on top of existing (since we only insert new turns) @@ -280,6 +328,8 @@ def upsert_sessions(conn, sessions): else: model_to_set = existing_model + # session_name: replace only when we have a new non-empty value, to avoid + # clobbering an existing name with a later scan that sees no rename records conn.execute(""" UPDATE sessions SET last_timestamp = MAX(last_timestamp, ?), @@ -288,13 +338,15 @@ def upsert_sessions(conn, sessions): total_cache_read = total_cache_read + ?, total_cache_creation = total_cache_creation + ?, turn_count = turn_count + ?, - model = ? + model = ?, + session_name = COALESCE(NULLIF(?, ''), session_name) WHERE session_id = ? """, ( s["last_timestamp"], s["total_input_tokens"], s["total_output_tokens"], s["total_cache_read"], s["total_cache_creation"], s["turn_count"], model_to_set, + s.get("session_name"), s["session_id"] )) @@ -314,10 +366,16 @@ def insert_turns(conn, turns): ]) +def delete_session_turns(conn, session_ids): + for session_id in session_ids: + conn.execute("DELETE FROM turns WHERE session_id = ?", (session_id,)) + + def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True): conn = get_db(db_path) init_db(conn) + include_cowork = projects_dir is None and projects_dirs is None if projects_dirs: dirs_to_scan = [Path(d) for d in projects_dirs] elif projects_dir: @@ -332,6 +390,11 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True): if verbose: print(f"Scanning {d} ...") jsonl_files.extend(glob.glob(str(d / "**" / "*.jsonl"), recursive=True)) + if include_cowork: + audit_dir = cowork.cowork_sessions_dir() + if audit_dir and verbose: + print(f"Scanning {audit_dir} ...") + jsonl_files.extend(str(path) for path in cowork.find_audit_files(audit_dir)) jsonl_files.sort() new_files = 0 @@ -362,7 +425,10 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True): if is_new: # New file: full parse (single read, returns line count) - session_metas, turns, line_count = parse_jsonl_file(filepath) + if cowork.is_audit_file(filepath): + session_metas, turns, line_count = cowork.parse_audit_file(filepath) + else: + session_metas, turns, line_count = parse_jsonl_file(filepath) if turns or session_metas: sessions = aggregate_sessions(session_metas, turns) @@ -374,113 +440,135 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True): new_files += 1 else: - # Updated file: read once, process only new lines - old_lines = row["lines"] if row else 0 - seen_messages = {} # message_id -> turn (dedup streaming) - turns_no_id = [] - new_session_metas = {} - line_count = 0 - - try: - with open(filepath, encoding="utf-8", errors="replace") as f: - for line_count, line in enumerate(f, 1): - if line_count <= old_lines: - continue - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError: - continue - - rtype = record.get("type") - if rtype not in ("assistant", "user"): - continue - - session_id = record.get("sessionId") - if not session_id: - continue - - timestamp = record.get("timestamp", "") - cwd = record.get("cwd", "") - - # Track session metadata from new lines - if session_id not in new_session_metas: - new_session_metas[session_id] = { - "session_id": session_id, - "project_name": project_name_from_cwd(cwd), - "first_timestamp": timestamp, - "last_timestamp": timestamp, - "git_branch": record.get("gitBranch", ""), - "model": None, - } - else: - meta = new_session_metas[session_id] - if timestamp and (not meta["last_timestamp"] or timestamp > meta["last_timestamp"]): - meta["last_timestamp"] = timestamp + if cowork.is_audit_file(filepath): + session_metas, turns, line_count = cowork.parse_audit_file(filepath) + if turns or session_metas: + session_ids = [meta["session_id"] for meta in session_metas] + delete_session_turns(conn, session_ids) + sessions = aggregate_sessions(session_metas, turns) + upsert_sessions(conn, sessions) + insert_turns(conn, turns) + for s in sessions: + total_sessions.add(s["session_id"]) + total_turns += len(turns) + updated_files += 1 + else: + # Updated file: read once, process only new lines + old_lines = row["lines"] if row else 0 + seen_messages = {} # message_id -> turn (dedup streaming) + turns_no_id = [] + new_session_metas = {} + line_count = 0 - if rtype == "assistant": - msg = record.get("message", {}) - usage = msg.get("usage", {}) - model = msg.get("model", "") - message_id = msg.get("id", "") + try: + with open(filepath, encoding="utf-8", errors="replace") as f: + for line_count, line in enumerate(f, 1): + if line_count <= old_lines: + continue + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue - input_tokens = usage.get("input_tokens", 0) or 0 - output_tokens = usage.get("output_tokens", 0) or 0 - cache_read = usage.get("cache_read_input_tokens", 0) or 0 - cache_creation = usage.get("cache_creation_input_tokens", 0) or 0 + session_id = record.get("sessionId") + if not session_id: + continue + + # Initialise meta on first sight, regardless of record type + if session_id not in new_session_metas: + new_session_metas[session_id] = { + "session_id": session_id, + "project_name": project_name_from_cwd(record.get("cwd", "")), + "first_timestamp": record.get("timestamp", ""), + "last_timestamp": record.get("timestamp", ""), + "git_branch": record.get("gitBranch", ""), + "model": None, + "custom_title": None, + "agent_name": None, + } + + # Capture session_name updates from any record + meta = new_session_metas[session_id] + meta["custom_title"], meta["agent_name"] = extract_session_name( + record, meta["custom_title"], meta["agent_name"] + ) - if input_tokens + output_tokens + cache_read + cache_creation == 0: + rtype = record.get("type") + if rtype not in ("assistant", "user"): continue - tool_name = None - for item in msg.get("content", []): - if isinstance(item, dict) and item.get("type") == "tool_use": - tool_name = item.get("name") - break - - if model: - new_session_metas[session_id]["model"] = model - - turn = { - "session_id": session_id, - "timestamp": timestamp, - "model": model, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "cache_read_tokens": cache_read, - "cache_creation_tokens": cache_creation, - "tool_name": tool_name, - "cwd": cwd, - "message_id": message_id, - } - - if message_id: - seen_messages[message_id] = turn - else: - turns_no_id.append(turn) - except Exception as e: - print(f" Warning: {e}") - - if line_count <= old_lines: - # File didn't grow (mtime changed but no new content) - conn.execute("UPDATE processed_files SET mtime = ? WHERE path = ?", - (mtime, filepath)) - conn.commit() - skipped_files += 1 - continue - - new_turns = turns_no_id + list(seen_messages.values()) - - if new_turns or new_session_metas: - sessions = aggregate_sessions(list(new_session_metas.values()), new_turns) - upsert_sessions(conn, sessions) - insert_turns(conn, new_turns) - for s in sessions: - total_sessions.add(s["session_id"]) - total_turns += len(new_turns) - updated_files += 1 + timestamp = record.get("timestamp", "") + cwd = record.get("cwd", "") + + if timestamp and (not meta["last_timestamp"] or timestamp > meta["last_timestamp"]): + meta["last_timestamp"] = timestamp + if cwd and (not meta["project_name"] or meta["project_name"] == "unknown"): + meta["project_name"] = project_name_from_cwd(cwd) + + if rtype == "assistant": + msg = record.get("message", {}) + usage = msg.get("usage", {}) + model = msg.get("model", "") + message_id = msg.get("id", "") + + input_tokens = usage.get("input_tokens", 0) or 0 + output_tokens = usage.get("output_tokens", 0) or 0 + cache_read = usage.get("cache_read_input_tokens", 0) or 0 + cache_creation = usage.get("cache_creation_input_tokens", 0) or 0 + + if input_tokens + output_tokens + cache_read + cache_creation == 0: + continue + + tool_name = None + for item in msg.get("content", []): + if isinstance(item, dict) and item.get("type") == "tool_use": + tool_name = item.get("name") + break + + if model: + new_session_metas[session_id]["model"] = model + + turn = { + "session_id": session_id, + "timestamp": timestamp, + "model": model, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_tokens": cache_read, + "cache_creation_tokens": cache_creation, + "tool_name": tool_name, + "cwd": cwd, + "message_id": message_id, + } + + if message_id: + seen_messages[message_id] = turn + else: + turns_no_id.append(turn) + except Exception as e: + print(f" Warning: {e}") + + if line_count <= old_lines: + # File didn't grow (mtime changed but no new content) + conn.execute("UPDATE processed_files SET mtime = ? WHERE path = ?", + (mtime, filepath)) + conn.commit() + skipped_files += 1 + continue + + new_turns = turns_no_id + list(seen_messages.values()) + + if new_turns or new_session_metas: + sessions = aggregate_sessions(list(new_session_metas.values()), new_turns) + upsert_sessions(conn, sessions) + insert_turns(conn, new_turns) + for s in sessions: + total_sessions.add(s["session_id"]) + total_turns += len(new_turns) + updated_files += 1 # Record file as processed (line_count already known from the single read) conn.execute(""" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 76287d1..5ea0e3d 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2,20 +2,25 @@ import json import os +import re import sqlite3 import tempfile import threading +import json import unittest import urllib.request from pathlib import Path from scanner import get_db, init_db, upsert_sessions, insert_turns -from dashboard import get_dashboard_data, DashboardHandler, HTML_TEMPLATE +from dashboard import ( + get_dashboard_data, + DashboardHandler, + HTML_TEMPLATE, + PRICING_JSON_PLACEHOLDER, + render_html, +) -try: - from http.server import HTTPServer -except ImportError: - HTTPServer = None +from http.server import ThreadingHTTPServer class TestGetDashboardData(unittest.TestCase): @@ -123,6 +128,51 @@ def test_hourly_by_model_carries_day_and_model(self): self.assertTrue(all(r["model"] == "claude-sonnet-4-6" for r in rows)) self.assertTrue(all(r["day"] == "2026-04-08" for r in rows)) + def test_session_name_field_present(self): + """sessions_all entries must always include a session_name key (empty by default).""" + data = get_dashboard_data(db_path=self.db_path) + session = data["sessions_all"][0] + self.assertIn("session_name", session) + self.assertEqual(session["session_name"], "") + + +class TestSessionNameInDashboard(unittest.TestCase): + """Verify session_name from the sessions table surfaces in dashboard output.""" + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmpfile.close() + self.db_path = Path(self.tmpfile.name) + conn = get_db(self.db_path) + init_db(conn) + conn.execute(""" + INSERT INTO sessions + (session_id, project_name, first_timestamp, last_timestamp, + git_branch, total_input_tokens, total_output_tokens, + total_cache_read, total_cache_creation, model, turn_count, + session_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + "named-session-xyz", "user/proj", + "2026-04-08T09:00:00Z", "2026-04-08T10:00:00Z", + "main", 100, 50, 0, 0, "claude-sonnet-4-6", 1, "clip-research", + )) + conn.commit() + conn.close() + + def tearDown(self): + os.unlink(self.db_path) + + def test_session_name_returned_in_api(self): + data = get_dashboard_data(db_path=self.db_path) + self.assertEqual(data["sessions_all"][0]["session_name"], "clip-research") + + def test_session_id_still_truncated_alongside_name(self): + data = get_dashboard_data(db_path=self.db_path) + session = data["sessions_all"][0] + self.assertEqual(len(session["session_id"]), 8) + self.assertEqual(session["session_id"], "named-se") + class TestDashboardHTTP(unittest.TestCase): """Integration test: start server and make HTTP requests.""" @@ -147,7 +197,7 @@ def setUpClass(cls): for (mod, name), (_orig, new) in cls._patches.items(): setattr(mod, name, new) - cls.server = HTTPServer(("127.0.0.1", 0), DashboardHandler) + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), DashboardHandler) cls.port = cls.server.server_address[1] cls.thread = threading.Thread(target=cls.server.serve_forever) cls.thread.daemon = True @@ -166,6 +216,49 @@ def test_index_returns_html(self): self.assertEqual(resp.status, 200) self.assertIn("text/html", resp.headers["Content-Type"]) + def test_index_responds_while_api_data_request_is_in_flight(self): + import dashboard as _d + + original_get_dashboard_data = _d.get_dashboard_data + api_started = threading.Event() + release_api = threading.Event() + api_result = {} + + def slow_get_dashboard_data(): + api_started.set() + release_api.wait(timeout=5) + return {"all_models": [], "daily_by_model": [], "sessions_all": []} + + def request_api_data(): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{self.port}/api/data", + timeout=5, + ) as resp: + api_result["status"] = resp.status + except Exception as exc: + api_result["error"] = exc + + _d.get_dashboard_data = slow_get_dashboard_data + api_thread = threading.Thread(target=request_api_data) + api_thread.start() + + try: + self.assertTrue(api_started.wait(timeout=2)) + with urllib.request.urlopen( + f"http://127.0.0.1:{self.port}/", + timeout=1, + ) as resp: + self.assertEqual(resp.status, 200) + self.assertIn("text/html", resp.headers["Content-Type"]) + finally: + release_api.set() + api_thread.join(timeout=5) + _d.get_dashboard_data = original_get_dashboard_data + + self.assertEqual(api_result.get("status"), 200) + self.assertNotIn("error", api_result) + def test_api_data_returns_json(self): url = f"http://127.0.0.1:{self.port}/api/data" with urllib.request.urlopen(url) as resp: @@ -194,12 +287,56 @@ def test_404_for_unknown_path(self): except urllib.error.HTTPError as e: self.assertEqual(e.code, 404) + def test_bookmarkable_url_range_all(self): + """GH#80: /?range=all must not 404 — query string must be stripped before routing.""" + url = f"http://127.0.0.1:{self.port}/?range=all" + with urllib.request.urlopen(url) as resp: + self.assertEqual(resp.status, 200) + self.assertIn("text/html", resp.headers["Content-Type"]) + + def test_bookmarkable_url_range_30d(self): + """GH#80: /?range=30d must not 404.""" + url = f"http://127.0.0.1:{self.port}/?range=30d" + with urllib.request.urlopen(url) as resp: + self.assertEqual(resp.status, 200) + self.assertIn("text/html", resp.headers["Content-Type"]) + + def test_api_data_with_cachebust_query_string(self): + """GH#80: /api/data?cachebust=1 must not 404.""" + url = f"http://127.0.0.1:{self.port}/api/data?cachebust=1" + with urllib.request.urlopen(url) as resp: + self.assertEqual(resp.status, 200) + self.assertIn("application/json", resp.headers["Content-Type"]) + class TestHTMLTemplate(unittest.TestCase): + def _extract_js_function(self, name): + signature = f"function {name}(" + start = HTML_TEMPLATE.index(signature) + brace_start = HTML_TEMPLATE.index("{", start) + depth = 0 + for idx in range(brace_start, len(HTML_TEMPLATE)): + char = HTML_TEMPLATE[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return HTML_TEMPLATE[start:idx + 1] + self.fail(f"Could not extract JavaScript function {name}") + def test_template_is_valid_html(self): self.assertIn("", HTML_TEMPLATE) self.assertIn("", HTML_TEMPLATE) + def test_template_renders_session_name_when_set(self): + """The sessions-table renderer must branch on session_name presence.""" + self.assertIn("s.session_name", HTML_TEMPLATE) + self.assertIn("session-name", HTML_TEMPLATE) + + def test_csv_export_includes_session_name(self): + self.assertIn("Session Name", HTML_TEMPLATE) + def test_template_has_esc_function(self): """Verify XSS protection is present (PR #10).""" self.assertIn("function esc(", HTML_TEMPLATE) @@ -223,45 +360,113 @@ def test_hourly_chart_canvas_present(self): self.assertIn('data-tz="local"', HTML_TEMPLATE) self.assertIn('data-tz="utc"', HTML_TEMPLATE) - def test_hourly_peak_hour_constants(self): - """Peak-hour set covers UTC 12–17 (Mon–Fri 05:00–11:00 PT).""" - self.assertIn('PEAK_HOURS_UTC', HTML_TEMPLATE) - self.assertIn('[12, 13, 14, 15, 16, 17]', HTML_TEMPLATE) - - -class TestPricingParity(unittest.TestCase): - """Verify CLI and dashboard pricing tables stay in sync.""" - - def _extract_js_pricing(self): - """Extract pricing values from the dashboard JS PRICING object.""" - import re - prices = {} - for match in re.finditer( - r"'(claude-[^']+)':\s*\{\s*input:\s*([\d.]+),\s*output:\s*([\d.]+)", - HTML_TEMPLATE - ): - model, inp, out = match.group(1), float(match.group(2)), float(match.group(3)) - prices[model] = {"input": inp, "output": out} - return prices - - def test_all_cli_models_in_dashboard(self): - from cli import PRICING as CLI_PRICING - js_prices = self._extract_js_pricing() - for model in CLI_PRICING: - self.assertIn(model, js_prices, f"{model} missing from dashboard JS") - - def test_prices_match(self): - from cli import PRICING as CLI_PRICING - js_prices = self._extract_js_pricing() - for model in CLI_PRICING: - self.assertAlmostEqual( - CLI_PRICING[model]["input"], js_prices[model]["input"], - msg=f"{model} input price mismatch" - ) - self.assertAlmostEqual( - CLI_PRICING[model]["output"], js_prices[model]["output"], - msg=f"{model} output price mismatch" - ) + def test_range_filter_uses_bounds_for_all_filtered_data(self): + """Regression for GH#88: range filtering must not reference undefined variables.""" + apply_filter = self._extract_js_function("applyFilter") + + bounds_decl = apply_filter.index("const { start, end } = getRangeBounds(selectedRange);") + daily_filter = apply_filter.index("rawData.daily_by_model.filter") + sessions_filter = apply_filter.index("rawData.sessions_all.filter") + hourly_filter = apply_filter.index("rawData.hourly_by_model || []") + + self.assertLess(bounds_decl, daily_filter) + self.assertLess(bounds_decl, sessions_filter) + self.assertLess(bounds_decl, hourly_filter) + self.assertNotRegex(apply_filter, r"\bcutoff\b") + for filter_start in [daily_filter, sessions_filter, hourly_filter]: + filter_block = apply_filter[filter_start:filter_start + 180] + self.assertIn("!start", filter_block) + self.assertIn("!end", filter_block) + + def test_template_handles_each_supported_range(self): + """Each selectable range needs UI, URL parsing, labels, ticks, and bounds support.""" + expected_ranges = ["7d", "30d", "90d", "all"] + get_bounds = self._extract_js_function("getRangeBounds") + read_url_range = self._extract_js_function("readURLRange") + + for range_name in expected_ranges: + self.assertIn(f'data-range="{range_name}"', HTML_TEMPLATE) + self.assertIn("VALID_RANGES.includes(p)", read_url_range) + self.assertRegex(HTML_TEMPLATE, rf"RANGE_LABELS\s*=\s*\{{[^}}]*'{re.escape(range_name)}':") + self.assertRegex(HTML_TEMPLATE, rf"RANGE_TICKS\s*=\s*\{{[^}}]*'{re.escape(range_name)}':") + + self.assertIn("range === 'all'", get_bounds) + self.assertIn("range === '7d' ? 7", get_bounds) + self.assertIn("range === '30d' ? 30", get_bounds) + self.assertIn(": 90", get_bounds) + + def test_read_url_models_falls_back_to_all_when_no_billable(self): + """Regression GH#106/GH#76: if no model names contain opus/sonnet/haiku, + readURLModels must select ALL models, not return an empty set that + causes the dashboard to show 0 data for every range including 'All'.""" + read_url_models = self._extract_js_function("readURLModels") + self.assertIn("billable.length", read_url_models) + self.assertIn("allModels", read_url_models) + + +class TestEmptyStringModel(unittest.TestCase): + """Regression GH#106: turns with model='' must be mapped to 'unknown', not ''.""" + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmpfile.close() + self.db_path = Path(self.tmpfile.name) + conn = get_db(self.db_path) + init_db(conn) + sessions = [{ + "session_id": "sess-nomodel", "project_name": "user/proj", + "first_timestamp": "2026-03-12T10:00:00Z", + "last_timestamp": "2026-03-12T11:00:00Z", + "git_branch": "main", "model": None, + "total_input_tokens": 1000, "total_output_tokens": 500, + "total_cache_read": 0, "total_cache_creation": 0, + "turn_count": 5, + }] + upsert_sessions(conn, sessions) + turns = [{ + "session_id": "sess-nomodel", "timestamp": "2026-03-12T10:30:00Z", + "model": "", + "input_tokens": 1000, "output_tokens": 500, + "cache_read_tokens": 0, "cache_creation_tokens": 0, + "tool_name": None, "cwd": "/tmp", "message_id": "", + }] + insert_turns(conn, turns) + conn.commit() + conn.close() + + def tearDown(self): + os.unlink(self.db_path) + + def test_empty_model_mapped_to_unknown_in_all_models(self): + data = get_dashboard_data(db_path=self.db_path) + self.assertIn("unknown", data["all_models"]) + self.assertNotIn("", data["all_models"]) + + def test_empty_model_mapped_to_unknown_in_daily(self): + data = get_dashboard_data(db_path=self.db_path) + models_in_daily = {r["model"] for r in data["daily_by_model"]} + self.assertIn("unknown", models_in_daily) + self.assertNotIn("", models_in_daily) + + def test_empty_model_mapped_to_unknown_in_hourly(self): + data = get_dashboard_data(db_path=self.db_path) + models_in_hourly = {r["model"] for r in data["hourly_by_model"]} + self.assertIn("unknown", models_in_hourly) + self.assertNotIn("", models_in_hourly) + + +class TestPricingInjection(unittest.TestCase): + """Verify dashboard pricing is generated from the Python source.""" + + def test_template_uses_pricing_placeholder(self): + self.assertIn(f"const PRICING = {PRICING_JSON_PLACEHOLDER};", HTML_TEMPLATE) + + def test_rendered_html_injects_python_pricing(self): + from pricing import PRICING + + html = render_html().decode("utf-8") + self.assertNotIn(PRICING_JSON_PLACEHOLDER, html) + self.assertIn(f"const PRICING = {json.dumps(PRICING)};", html) if __name__ == "__main__": diff --git a/tests/test_scanner.py b/tests/test_scanner.py index fa1dc07..e53f9d0 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -6,13 +6,32 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch +import cowork from scanner import ( get_db, init_db, project_name_from_cwd, parse_jsonl_file, aggregate_sessions, upsert_sessions, insert_turns, scan, + resolve_session_name, ) +def _make_custom_title_record(session_id="sess-1", title="my-session"): + return json.dumps({ + "type": "custom-title", + "customTitle": title, + "sessionId": session_id, + }) + + +def _make_agent_name_record(session_id="sess-1", name="my-agent"): + return json.dumps({ + "type": "agent-name", + "agentName": name, + "sessionId": session_id, + }) + + class TestProjectNameFromCwd(unittest.TestCase): def test_two_components(self): self.assertEqual(project_name_from_cwd("/home/user/myproject"), "user/myproject") @@ -73,6 +92,25 @@ def _make_user_record(session_id="sess-1", timestamp="2026-04-08T09:59:00Z", }) +def _make_audit_result(session_id="abc12345-session", timestamp="2026-04-25T12:00:03Z", + model_usage=None): + return json.dumps({ + "type": "result", + "session_id": session_id, + "_audit_timestamp": timestamp, + "total_cost_usd": 0.42, + "modelUsage": model_usage or { + "claude-opus-4-7": { + "inputTokens": 100, + "outputTokens": 200, + "cacheReadInputTokens": 1000, + "cacheCreationInputTokens": 50, + "costUSD": 0.40, + } + }, + }) + + class TestParseJsonlFile(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -165,6 +203,117 @@ def test_tool_name_extracted(self): self.assertEqual(turns[0]["tool_name"], "Read") +class TestCoworkAuditPaths(unittest.TestCase): + def test_macos_path(self): + with patch("cowork.sys.platform", "darwin"), patch.object(Path, "home", return_value=Path("/Users/me")): + self.assertEqual( + cowork.cowork_sessions_dir(), + Path("/Users/me/Library/Application Support/Claude/local-agent-mode-sessions"), + ) + + def test_windows_path(self): + with patch("cowork.sys.platform", "win32"), patch.dict(os.environ, {"APPDATA": "C:\\Users\\me\\AppData\\Roaming"}): + self.assertEqual( + cowork.cowork_sessions_dir(), + Path("C:\\Users\\me\\AppData\\Roaming") / "Claude" / "local-agent-mode-sessions", + ) + + def test_windows_without_appdata_returns_none(self): + with patch("cowork.sys.platform", "win32"), patch.dict(os.environ, {}, clear=True): + self.assertIsNone(cowork.cowork_sessions_dir()) + + def test_linux_path_uses_xdg_config_home(self): + with patch("cowork.sys.platform", "linux"), patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/config"}): + self.assertEqual( + cowork.cowork_sessions_dir(), + Path("/tmp/config/Claude/local-agent-mode-sessions"), + ) + + def test_linux_path_defaults_to_home_config(self): + with patch("cowork.sys.platform", "linux"), patch.dict(os.environ, {}, clear=True), patch.object(Path, "home", return_value=Path("/home/me")): + self.assertEqual( + cowork.cowork_sessions_dir(), + Path("/home/me/.config/Claude/local-agent-mode-sessions"), + ) + + +class TestParseCoworkAuditFile(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def _write_audit(self, lines): + path = Path(self.tmpdir) / "audit.jsonl" + with open(path, "w") as f: + for line in lines: + f.write(line + "\n") + return path + + def test_result_model_usage_becomes_turns(self): + path = self._write_audit([_make_audit_result()]) + metas, turns, line_count = cowork.parse_audit_file(path) + + self.assertEqual(line_count, 1) + self.assertEqual(len(metas), 1) + self.assertEqual(metas[0]["session_id"], "abc12345-session") + self.assertEqual(metas[0]["project_name"], "Cowork/abc12345") + self.assertEqual(metas[0]["first_timestamp"], "2026-04-25T12:00:03Z") + self.assertEqual(len(turns), 1) + self.assertEqual(turns[0]["model"], "claude-opus-4-7") + self.assertEqual(turns[0]["input_tokens"], 100) + self.assertEqual(turns[0]["output_tokens"], 200) + self.assertEqual(turns[0]["cache_read_tokens"], 1000) + self.assertEqual(turns[0]["cache_creation_tokens"], 50) + self.assertEqual(turns[0]["message_id"], "cowork:abc12345-session:claude-opus-4-7") + + def test_tier_hints_are_stripped_and_merged(self): + path = self._write_audit([_make_audit_result(model_usage={ + "claude-haiku-4-5[1m]": { + "inputTokens": 100, + "outputTokens": 10, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }, + "claude-haiku-4-5": { + "inputTokens": 50, + "outputTokens": 5, + "cacheReadInputTokens": 3, + "cacheCreationInputTokens": 2, + }, + })]) + _, turns, _ = cowork.parse_audit_file(path) + + self.assertEqual(len(turns), 1) + self.assertEqual(turns[0]["model"], "claude-haiku-4-5") + self.assertEqual(turns[0]["input_tokens"], 150) + self.assertEqual(turns[0]["output_tokens"], 15) + self.assertEqual(turns[0]["cache_read_tokens"], 3) + self.assertEqual(turns[0]["cache_creation_tokens"], 2) + + def test_last_result_for_session_wins(self): + path = self._write_audit([ + _make_audit_result(model_usage={ + "claude-opus-4-7": { + "inputTokens": 100, + "outputTokens": 10, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }, + }), + _make_audit_result(timestamp="2026-04-25T12:10:00Z", model_usage={ + "claude-opus-4-7": { + "inputTokens": 200, + "outputTokens": 20, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }, + }), + ]) + metas, turns, _ = cowork.parse_audit_file(path) + + self.assertEqual(metas[0]["last_timestamp"], "2026-04-25T12:10:00Z") + self.assertEqual(turns[0]["input_tokens"], 200) + + class TestMessageIdDedup(unittest.TestCase): """Test deduplication of streaming events by message.id.""" @@ -436,6 +585,7 @@ def setUp(self): self.projects_dir = Path(self.tmpdir) / "projects" self.projects_dir.mkdir() self.db_path = Path(self.tmpdir) / "usage.db" + self.audit_dir = Path(self.tmpdir) / "Claude" / "local-agent-mode-sessions" def _write_project_jsonl(self, project_name, session_id, num_turns=3): project_dir = self.projects_dir / project_name @@ -480,6 +630,79 @@ def test_scan_multiple_files(self): self.assertEqual(result["turns"], 6) self.assertEqual(result["sessions"], 2) + def test_default_scan_includes_cowork_audits(self): + session_dir = self.audit_dir / "device" / "account" / "local_abc12345-session" + session_dir.mkdir(parents=True) + with open(session_dir / "audit.jsonl", "w") as f: + f.write(_make_audit_result() + "\n") + + with patch("scanner.DEFAULT_PROJECTS_DIRS", [self.projects_dir]), \ + patch("cowork.cowork_sessions_dir", return_value=self.audit_dir): + result = scan(db_path=self.db_path, verbose=False) + + self.assertEqual(result["new"], 1) + self.assertEqual(result["turns"], 1) + self.assertEqual(result["sessions"], 1) + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + session = conn.execute("SELECT * FROM sessions WHERE session_id = ?", ("abc12345-session",)).fetchone() + turn = conn.execute("SELECT * FROM turns WHERE session_id = ?", ("abc12345-session",)).fetchone() + conn.close() + self.assertEqual(session["project_name"], "Cowork/abc12345") + self.assertEqual(session["total_input_tokens"], 100) + self.assertEqual(turn["model"], "claude-opus-4-7") + + def test_explicit_scan_dir_does_not_include_cowork_audits(self): + session_dir = self.audit_dir / "device" / "account" / "local_abc12345-session" + session_dir.mkdir(parents=True) + with open(session_dir / "audit.jsonl", "w") as f: + f.write(_make_audit_result() + "\n") + + with patch("cowork.cowork_sessions_dir", return_value=self.audit_dir): + result = scan(projects_dir=self.projects_dir, db_path=self.db_path, verbose=False) + + self.assertEqual(result["new"], 0) + self.assertEqual(result["turns"], 0) + + def test_updated_cowork_audit_replaces_cumulative_totals(self): + session_dir = self.audit_dir / "device" / "account" / "local_abc12345-session" + session_dir.mkdir(parents=True) + audit_path = session_dir / "audit.jsonl" + with open(audit_path, "w") as f: + f.write(_make_audit_result() + "\n") + + with patch("scanner.DEFAULT_PROJECTS_DIRS", [self.projects_dir]), \ + patch("cowork.cowork_sessions_dir", return_value=self.audit_dir): + scan(db_path=self.db_path, verbose=False) + + import time + time.sleep(0.05) + with open(audit_path, "a") as f: + f.write(_make_audit_result(model_usage={ + "claude-opus-4-7": { + "inputTokens": 300, + "outputTokens": 400, + "cacheReadInputTokens": 500, + "cacheCreationInputTokens": 60, + } + }) + "\n") + result = scan(db_path=self.db_path, verbose=False) + + self.assertEqual(result["updated"], 1) + self.assertEqual(result["turns"], 1) + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + session = conn.execute("SELECT * FROM sessions WHERE session_id = ?", ("abc12345-session",)).fetchone() + turn_count = conn.execute("SELECT COUNT(*) FROM turns WHERE session_id = ?", ("abc12345-session",)).fetchone()[0] + conn.close() + self.assertEqual(turn_count, 1) + self.assertEqual(session["total_input_tokens"], 300) + self.assertEqual(session["total_output_tokens"], 400) + self.assertEqual(session["total_cache_read"], 500) + self.assertEqual(session["total_cache_creation"], 60) + class TestScanIncrementalUpdate(unittest.TestCase): """Test that updating a file only processes new lines (no double reads).""" @@ -645,5 +868,226 @@ def test_empty_file_returns_zero(self): self.assertEqual(line_count, 0) +class TestResolveSessionName(unittest.TestCase): + def test_prefers_custom_title(self): + self.assertEqual(resolve_session_name("ct", "an"), "ct") + + def test_falls_back_to_agent_name(self): + self.assertEqual(resolve_session_name(None, "an"), "an") + + def test_empty_custom_title_falls_back(self): + self.assertEqual(resolve_session_name("", "an"), "an") + + def test_both_empty_returns_none(self): + self.assertIsNone(resolve_session_name(None, None)) + self.assertIsNone(resolve_session_name("", "")) + + +class TestSessionNameParsing(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def _write_jsonl(self, filename, lines): + path = os.path.join(self.tmpdir, filename) + with open(path, "w") as f: + for line in lines: + f.write(line + "\n") + return path + + def test_custom_title_captured(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_custom_title_record(title="clip-research"), + _make_assistant_record(), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertEqual(sessions[0]["session_name"], "clip-research") + + def test_agent_name_fallback(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_agent_name_record(name="helper-bot"), + _make_assistant_record(), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertEqual(sessions[0]["session_name"], "helper-bot") + + def test_custom_title_preferred_over_agent_name(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_agent_name_record(name="agent-first"), + _make_custom_title_record(title="chosen-title"), + _make_assistant_record(), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertEqual(sessions[0]["session_name"], "chosen-title") + + def test_last_custom_title_wins_after_rename(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_custom_title_record(title="first-name"), + _make_assistant_record(), + _make_custom_title_record(title="renamed"), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertEqual(sessions[0]["session_name"], "renamed") + + def test_no_rename_records_yields_none(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_assistant_record(), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertIsNone(sessions[0]["session_name"]) + + def test_empty_custom_title_ignored(self): + path = self._write_jsonl("t.jsonl", [ + _make_user_record(), + _make_custom_title_record(title="real-title"), + _make_custom_title_record(title=""), # empty — must not clobber + _make_assistant_record(), + ]) + metas, _, _ = parse_jsonl_file(path) + sessions = aggregate_sessions(metas, []) + self.assertEqual(sessions[0]["session_name"], "real-title") + + +class TestSessionNameIntegration(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.projects_dir = Path(self.tmpdir) / "projects" / "user" / "proj" + self.projects_dir.mkdir(parents=True) + self.db_path = Path(self.tmpdir) / "usage.db" + self.filepath = self.projects_dir / "sess-1.jsonl" + + def test_session_name_persisted_on_scan(self): + with open(self.filepath, "w") as f: + f.write(_make_user_record(session_id="sess-1") + "\n") + f.write(_make_custom_title_record(session_id="sess-1", title="clip-research") + "\n") + f.write(_make_assistant_record(session_id="sess-1", message_id="msg-1") + "\n") + + scan(projects_dir=self.projects_dir.parent.parent, + db_path=self.db_path, verbose=False) + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT session_name FROM sessions WHERE session_id='sess-1'").fetchone() + conn.close() + self.assertEqual(row["session_name"], "clip-research") + + def test_session_name_preserved_when_incremental_scan_has_no_rename(self): + """Appending assistant turns without rename records must not blank out session_name.""" + # Initial scan: session gets named + with open(self.filepath, "w") as f: + f.write(_make_user_record(session_id="sess-1") + "\n") + f.write(_make_custom_title_record(session_id="sess-1", title="keep-me") + "\n") + f.write(_make_assistant_record(session_id="sess-1", message_id="msg-1") + "\n") + scan(projects_dir=self.projects_dir.parent.parent, + db_path=self.db_path, verbose=False) + + # Append a turn without any rename record + import time + time.sleep(0.05) + with open(self.filepath, "a") as f: + f.write(_make_assistant_record(session_id="sess-1", message_id="msg-2", + timestamp="2026-04-08T10:05:00Z") + "\n") + scan(projects_dir=self.projects_dir.parent.parent, + db_path=self.db_path, verbose=False) + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT session_name FROM sessions WHERE session_id='sess-1'").fetchone() + conn.close() + self.assertEqual(row["session_name"], "keep-me") + + def test_session_name_updates_on_rename_during_incremental_scan(self): + """A rename captured in appended lines should replace the stored session_name.""" + with open(self.filepath, "w") as f: + f.write(_make_user_record(session_id="sess-1") + "\n") + f.write(_make_custom_title_record(session_id="sess-1", title="old-name") + "\n") + f.write(_make_assistant_record(session_id="sess-1", message_id="msg-1") + "\n") + scan(projects_dir=self.projects_dir.parent.parent, + db_path=self.db_path, verbose=False) + + import time + time.sleep(0.05) + with open(self.filepath, "a") as f: + f.write(_make_custom_title_record(session_id="sess-1", title="new-name") + "\n") + f.write(_make_assistant_record(session_id="sess-1", message_id="msg-2", + timestamp="2026-04-08T10:05:00Z") + "\n") + scan(projects_dir=self.projects_dir.parent.parent, + db_path=self.db_path, verbose=False) + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT session_name FROM sessions WHERE session_id='sess-1'").fetchone() + conn.close() + self.assertEqual(row["session_name"], "new-name") + + +class TestSessionNameMigration(unittest.TestCase): + """Existing DBs without a session_name column should be upgraded by init_db.""" + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmpfile.close() + self.db_path = Path(self.tmpfile.name) + + def tearDown(self): + os.unlink(self.db_path) + + def test_session_name_column_added_to_existing_db(self): + # Seed a pre-migration schema (no session_name column). Mirror the legacy + # shape init_db expects to find so the upgrade path (ALTER TABLE) runs. + conn = sqlite3.connect(self.db_path) + conn.executescript(""" + CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + project_name TEXT, + first_timestamp TEXT, + last_timestamp TEXT, + git_branch TEXT, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + total_cache_read INTEGER DEFAULT 0, + total_cache_creation INTEGER DEFAULT 0, + model TEXT, + turn_count INTEGER DEFAULT 0 + ); + CREATE TABLE turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp TEXT, + model TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_creation_tokens INTEGER DEFAULT 0, + tool_name TEXT, + cwd TEXT + ); + CREATE TABLE processed_files ( + path TEXT PRIMARY KEY, + mtime REAL, + lines INTEGER + ); + """) + conn.commit() + cols_before = [r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()] + self.assertNotIn("session_name", cols_before) + conn.close() + + conn = get_db(self.db_path) + init_db(conn) + cols = [r["name"] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()] + self.assertIn("session_name", cols) + conn.close() + + if __name__ == "__main__": unittest.main()