diff --git a/scripts/run-server.sh b/scripts/run-server.sh index b38e9d1..9f01810 100755 --- a/scripts/run-server.sh +++ b/scripts/run-server.sh @@ -5,4 +5,9 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$HERE" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8732}" -exec uv run python -m uvicorn tracker.api:app --host "$HOST" --port "$PORT" +# --reload watches tracker/ and web/ so editing source rebuilds the running process. +# Set RELOAD=0 to opt out (e.g. when running under launchd). +RELOAD_FLAG="--reload" +[ "${RELOAD:-1}" = "0" ] && RELOAD_FLAG="" +exec uv run python -m uvicorn tracker.api:app --host "$HOST" --port "$PORT" \ + $RELOAD_FLAG --reload-dir tracker --reload-dir web diff --git a/tracker/api.py b/tracker/api.py index d3edbd8..677c176 100644 --- a/tracker/api.py +++ b/tracker/api.py @@ -11,7 +11,7 @@ from .db import DEFAULT_DB_PATH, connect, init from .ingest import run as run_ingest -from .pricing import _lookup as _price_lookup +from .pricing import _lookup as _price_lookup, reload as _price_reload from .recompute_costs import run as run_recompute ROOT = Path(__file__).resolve().parent.parent @@ -29,8 +29,12 @@ def db(): conn.close() -def _filter_clause(tool, model, project, start, end, prefix="m"): - """Returns (sql_fragment, params). All filters optional.""" +def _filter_clause(tool, model, project, start, end, agent=None, entrypoint=None, prefix="m"): + """Returns (sql_fragment, params). All filters optional. + + `agent` semantics: None = no filter; "main" = agent_type IS NULL (top-level session + turns); any other value = exact match on agent_type. + """ clauses = [] params: list = [] if tool: @@ -48,6 +52,14 @@ def _filter_clause(tool, model, project, start, end, prefix="m"): if project: clauses.append("s.cwd = ?") params.append(project) + if agent == "main": + clauses.append(f"{prefix}.agent_type IS NULL") + elif agent: + clauses.append(f"{prefix}.agent_type = ?") + params.append(agent) + if entrypoint: + clauses.append("s.entrypoint = ?") + params.append(entrypoint) where = " AND ".join(clauses) return (f" WHERE {where}" if where else ""), params @@ -73,12 +85,18 @@ def filters(): "SELECT DISTINCT model FROM messages WHERE model IS NOT NULL ORDER BY model")] projects = [r[0] for r in c.execute( "SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY cwd")] + agents = [r[0] for r in c.execute( + "SELECT DISTINCT agent_type FROM messages WHERE agent_type IS NOT NULL ORDER BY agent_type")] + entrypoints = [r[0] for r in c.execute( + "SELECT DISTINCT entrypoint FROM sessions WHERE entrypoint IS NOT NULL ORDER BY entrypoint")] date_range = c.execute( "SELECT MIN(ts), MAX(ts) FROM messages").fetchone() return { "tools": tools, "models": models, "projects": projects, + "agents": agents, + "entrypoints": entrypoints, "date_range": {"min": date_range[0], "max": date_range[1]}, } @@ -121,10 +139,12 @@ def stats( project: str | None = Query(None), start: str | None = Query(None), end: str | None = Query(None), + agent: str | None = Query(None), + entrypoint: str | None = Query(None), granularity: str = Query("auto"), ): """Totals + time-series + breakdowns. granularity: auto|minute|hour|day|week|month.""" - where, params = _filter_clause(tool, model, project, start, end) + where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint) join = "FROM messages m JOIN sessions s ON s.id = m.session_id" + where gran = granularity if granularity in _GRANULARITY else _pick_granularity(start, end) @@ -147,6 +167,27 @@ def stats( {join}""", params).fetchone() totals = dict(totals_row) + # Cost breakdown by bucket. Group tokens by (tool, model), apply per-bucket rates, + # then sum. Lets the UI show "where the $ went" (cache reads almost always dominate). + by_tm = c.execute( + f"""SELECT m.tool, m.model, + SUM(m.input_tokens) AS in_tok, + SUM(m.output_tokens) AS out_tok, + SUM(m.cache_read) AS cr_tok, + SUM(m.cache_write_5m) AS cw5_tok, + SUM(m.cache_write_1h) AS cw1_tok + {join} + GROUP BY m.tool, m.model""", params).fetchall() + cb = {"input": 0.0, "output": 0.0, "cache_read": 0.0, "cache_write_5m": 0.0, "cache_write_1h": 0.0} + for r in by_tm: + p = _price_lookup(r["tool"], r["model"]) + cb["input"] += (r["in_tok"] or 0) * p.get("input", 0) / 1_000_000 + cb["output"] += (r["out_tok"] or 0) * p.get("output", 0) / 1_000_000 + cb["cache_read"] += (r["cr_tok"] or 0) * p.get("cache_read", 0) / 1_000_000 + cb["cache_write_5m"] += (r["cw5_tok"] or 0) * p.get("cache_write_5m", 0) / 1_000_000 + cb["cache_write_1h"] += (r["cw1_tok"] or 0) * p.get("cache_write_1h", 0) / 1_000_000 + totals["cost_breakdown"] = {k: round(v, 4) for k, v in cb.items()} + # Active hours: sum over sessions of (max(ts) - min(ts)) within the filter window. # This excludes pure idle gaps between sessions and gives a more meaningful rate. active_row = c.execute( @@ -205,6 +246,39 @@ def stats( GROUP BY m.model, m.tool ORDER BY cost_usd DESC""", params).fetchall()] + # by entrypoint (cli / sdk-cli / codex / …). Tells "interactive REPL" from "spawned SDK runs". + by_entrypoint = [dict(r) for r in c.execute( + f"""SELECT COALESCE(s.entrypoint,'(unknown)') AS entrypoint, + COUNT(*) msgs, + COUNT(DISTINCT m.session_id) sessions, + SUM(m.input_tokens) input_tokens, + SUM(m.output_tokens) output_tokens, + SUM(m.cache_read) cache_hit, + SUM(m.cache_write_5m) cache_write_5m, + SUM(m.cache_write_1h) cache_write_1h, + SUM(m.est_cost_usd) cost_usd + {join} + GROUP BY s.entrypoint + ORDER BY cost_usd DESC""", params).fetchall()] + + # by agent INVOCATION (one row per sub-agent run, identified by agent_id). + # All main-session turns (agent_type IS NULL) collapse into a single aggregate row. + by_agent = [dict(r) for r in c.execute( + f"""SELECT COALESCE(m.agent_type,'main session') AS agent_type, + m.agent_id, + m.agent_desc, + COUNT(*) msgs, + COUNT(DISTINCT m.session_id) sessions, + SUM(m.input_tokens) input_tokens, + SUM(m.output_tokens) output_tokens, + SUM(m.cache_read) cache_hit, + SUM(m.cache_write_5m) cache_write_5m, + SUM(m.cache_write_1h) cache_write_1h, + SUM(m.est_cost_usd) cost_usd + {join} + GROUP BY m.agent_type, m.agent_id, m.agent_desc + ORDER BY cost_usd DESC""", params).fetchall()] + # by project by_project = [dict(r) for r in c.execute( f"""SELECT COALESCE(s.cwd,'(unknown)') AS project, @@ -235,17 +309,21 @@ def stats( "by_tool": by_tool, "by_model": by_model, "by_project": by_project, + "by_agent": by_agent, + "by_entrypoint": by_entrypoint, } @app.get("/api/breakdown_series") def breakdown_series( - group: str = Query("model", pattern="^(tool|model|project|session|server|mcp_tool)$"), + group: str = Query("model", pattern="^(tool|model|project|session|server|mcp_tool|agent|entrypoint)$"), tool: str | None = Query(None), model: str | None = Query(None), project: str | None = Query(None), start: str | None = Query(None), end: str | None = Query(None), + agent: str | None = Query(None), + entrypoint: str | None = Query(None), granularity: str = Query("auto"), limit: int = Query(5, ge=1, le=10), ): @@ -262,6 +340,16 @@ def breakdown_series( clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " "AND mm.source_line=mc.source_line AND mm.model = ?)") params.append(model) + if agent == "main": + clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " + "AND mm.source_line=mc.source_line AND mm.agent_type IS NULL)") + elif agent: + clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " + "AND mm.source_line=mc.source_line AND mm.agent_type = ?)") + params.append(agent) + if entrypoint: + clauses.append("s.entrypoint = ?") + params.append(entrypoint) if project: clauses.append("s.cwd = ?"); params.append(project) if start: @@ -309,10 +397,18 @@ def breakdown_series( "COALESCE(m.model,'(unknown)') || ' · ' || m.tool"), "project": ("COALESCE(s.cwd,'(unknown)')", "COALESCE(s.cwd,'(unknown)')"), "session": ("s.id", "s.tool || ' · ' || COALESCE(s.cwd, s.id)"), + "agent": ( + "COALESCE(m.agent_id, COALESCE(m.agent_type,'main session'))", + "CASE WHEN m.agent_id IS NULL THEN COALESCE(m.agent_type,'main session') " + " ELSE COALESCE(m.agent_type,'') || ' · ' || substr(m.agent_id,1,8) || " + " ' · ' || COALESCE(m.agent_desc,'(no description)') END", + ), + "entrypoint": ("COALESCE(s.entrypoint,'(unknown)')", + "COALESCE(s.entrypoint,'(unknown)')"), } key_expr, label_expr = group_exprs[group] bucket = _GRANULARITY[gran] - where, params = _filter_clause(tool, model, project, start, end) + where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint) join = "FROM messages m JOIN sessions s ON s.id = m.session_id" + where with db() as c: @@ -366,10 +462,12 @@ def sessions( project: str | None = Query(None), start: str | None = Query(None), end: str | None = Query(None), + agent: str | None = Query(None), + entrypoint: str | None = Query(None), limit: int = Query(200, ge=1, le=2000), sort: str = Query("cost", pattern="^(cost|recent|messages)$"), ): - where, params = _filter_clause(tool, model, project, start, end) + where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint) order = { "cost": "est_cost_usd DESC", "recent": "ended_at DESC", @@ -383,6 +481,7 @@ def sessions( s.session_uuid, s.cwd, s.model, + s.entrypoint, MIN(m.ts) AS started_at, MAX(m.ts) AS ended_at, COUNT(*) AS msg_count, @@ -409,8 +508,10 @@ def session_detail( project: str | None = Query(None), start: str | None = Query(None), end: str | None = Query(None), + agent: str | None = Query(None), + entrypoint: str | None = Query(None), ): - filters, filter_params = _filter_clause(tool, model, project, start, end) + filters, filter_params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint) where = " WHERE m.session_id = ?" + filters.replace(" WHERE", " AND", 1) params = [session_id, *filter_params] with db() as c: @@ -440,7 +541,8 @@ def session_detail( ).fetchone() msgs = [dict(r) for r in c.execute( f"""SELECT m.ts, m.model, m.input_tokens, m.output_tokens, m.cache_read, - m.cache_write_5m, m.cache_write_1h, m.reasoning_tokens, m.est_cost_usd + m.cache_write_5m, m.cache_write_1h, m.reasoning_tokens, m.est_cost_usd, + m.agent_type, m.agent_desc, m.agent_id FROM messages m JOIN sessions s ON s.id = m.session_id {where} ORDER BY m.ts""", @@ -496,6 +598,8 @@ def mcp( project: str | None = Query(None), start: str | None = Query(None), end: str | None = Query(None), + agent: str | None = Query(None), + entrypoint: str | None = Query(None), ): """MCP usage breakdown: by server, by server+tool_name. Cost attribution explained inline: result tokens × cache_read rate of the session's model (since MCP results @@ -509,6 +613,16 @@ def mcp( clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " "AND mm.source_line=mc.source_line AND mm.model = ?)") params.append(model) + if agent == "main": + clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " + "AND mm.source_line=mc.source_line AND mm.agent_type IS NULL)") + elif agent: + clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file " + "AND mm.source_line=mc.source_line AND mm.agent_type = ?)") + params.append(agent) + if entrypoint: + clauses.append("s.entrypoint = ?") + params.append(entrypoint) if project: clauses.append("s.cwd = ?"); params.append(project) if start: diff --git a/tracker/db.py b/tracker/db.py index 82a8c11..9626f26 100644 --- a/tracker/db.py +++ b/tracker/db.py @@ -11,6 +11,7 @@ session_uuid TEXT NOT NULL, cwd TEXT, model TEXT, -- last model observed in session + entrypoint TEXT, -- 'cli' (interactive), 'sdk-cli', 'codex', ... — how the run was launched started_at TEXT, ended_at TEXT, msg_count INTEGER NOT NULL DEFAULT 0, @@ -37,12 +38,16 @@ est_cost_usd REAL NOT NULL DEFAULT 0, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, + agent_type TEXT, -- e.g. 'Explore', 'Plan' for sub-agents; NULL for the main session + agent_desc TEXT, -- per-invocation description from the meta.json sidecar + agent_id TEXT, -- per-invocation hash from the JSONL filename (the sub-agent "PID") UNIQUE(source_file, source_line) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts); CREATE INDEX IF NOT EXISTS idx_messages_tool ON messages(tool); CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model); +-- idx_messages_agent_type is created in init() after the column-add migration runs. CREATE TABLE IF NOT EXISTS mcp_calls ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -99,6 +104,19 @@ def init(db_path: Path | str = DEFAULT_DB_PATH) -> None: conn = connect(db_path) try: conn.executescript(SCHEMA) + # Lightweight column migrations for existing DBs (CREATE TABLE IF NOT EXISTS won't add new columns). + existing = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")} + if "agent_type" not in existing: + conn.execute("ALTER TABLE messages ADD COLUMN agent_type TEXT") + if "agent_desc" not in existing: + conn.execute("ALTER TABLE messages ADD COLUMN agent_desc TEXT") + if "agent_id" not in existing: + conn.execute("ALTER TABLE messages ADD COLUMN agent_id TEXT") + conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_agent_type ON messages(agent_type)") + existing_s = {row["name"] for row in conn.execute("PRAGMA table_info(sessions)")} + if "entrypoint" not in existing_s: + conn.execute("ALTER TABLE sessions ADD COLUMN entrypoint TEXT") + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_entrypoint ON sessions(entrypoint)") conn.commit() finally: conn.close() diff --git a/tracker/ingest.py b/tracker/ingest.py index c4c448b..5e861ad 100644 --- a/tracker/ingest.py +++ b/tracker/ingest.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone from pathlib import Path +import json + from . import parse_claude, parse_codex from .db import DEFAULT_DB_PATH, connect, init from .pricing import cost_usd @@ -46,17 +48,18 @@ def _put_state(conn: sqlite3.Connection, path: Path, offset: int, mtime: float, def _upsert_session(conn: sqlite3.Connection, meta) -> None: conn.execute( - """INSERT INTO sessions(id, tool, session_uuid, cwd, model, started_at, ended_at) - VALUES(?,?,?,?,?,?,?) + """INSERT INTO sessions(id, tool, session_uuid, cwd, model, entrypoint, started_at, ended_at) + VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET - cwd = COALESCE(excluded.cwd, sessions.cwd), - model = COALESCE(excluded.model, sessions.model), - started_at= CASE WHEN sessions.started_at IS NULL OR excluded.started_atsessions.ended_at - THEN excluded.ended_at ELSE sessions.ended_at END""", + cwd = COALESCE(excluded.cwd, sessions.cwd), + model = COALESCE(excluded.model, sessions.model), + entrypoint = COALESCE(excluded.entrypoint, sessions.entrypoint), + started_at = CASE WHEN sessions.started_at IS NULL OR excluded.started_atsessions.ended_at + THEN excluded.ended_at ELSE sessions.ended_at END""", (meta.session_id, meta.tool, meta.session_uuid, meta.cwd, meta.model, - meta.started_at, meta.ended_at), + getattr(meta, "entrypoint", None), meta.started_at, meta.ended_at), ) @@ -78,13 +81,24 @@ def _insert_messages(conn: sqlite3.Connection, rows) -> int: """INSERT OR IGNORE INTO messages (session_id, tool, ts, model, input_tokens, output_tokens, cache_read, cache_write_5m, cache_write_1h, reasoning_tokens, est_cost_usd, - source_file, source_line) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + source_file, source_line, agent_type, agent_desc, agent_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (r.session_id, r.tool, r.ts, r.model, r.input_tokens, r.output_tokens, r.cache_read, r.cache_write_5m, r.cache_write_1h, r.reasoning_tokens, cost, - r.source_file, r.source_line), + r.source_file, r.source_line, + getattr(r, "agent_type", None), getattr(r, "agent_desc", None), + getattr(r, "agent_id", None)), ) added += cur.rowcount + # Existing rows (already-ingested sub-agent files) won't be re-inserted; backfill the agent tags. + if cur.rowcount == 0 and (getattr(r, "agent_type", None) or getattr(r, "agent_id", None)): + conn.execute( + """UPDATE messages SET agent_type=COALESCE(agent_type,?), + agent_desc=COALESCE(agent_desc,?), + agent_id=COALESCE(agent_id,?) + WHERE source_file=? AND source_line=?""", + (r.agent_type, r.agent_desc, r.agent_id, r.source_file, r.source_line), + ) return added @@ -173,6 +187,56 @@ def run(db_path: Path | str = DEFAULT_DB_PATH, *, verbose: bool = False) -> dict error = None try: + # One-shot backfill: codex sessions and any claude session that pre-dates entrypoint capture. + conn.execute("UPDATE sessions SET entrypoint='codex' WHERE tool='codex' AND entrypoint IS NULL") + for sid, in conn.execute( + "SELECT id FROM sessions WHERE tool='claude' AND entrypoint IS NULL" + ).fetchall(): + row = conn.execute( + "SELECT source_file FROM messages WHERE session_id=? LIMIT 1", (sid,) + ).fetchone() + if not row: + continue + ep = None + try: + with open(row["source_file"]) as f: + for i, line in enumerate(f): + if i > 30: + break + try: + d = json.loads(line) + except json.JSONDecodeError: + continue + if d.get("entrypoint"): + ep = d["entrypoint"] + break + except OSError: + continue + if ep: + conn.execute("UPDATE sessions SET entrypoint=? WHERE id=?", (ep, sid)) + + # One-shot backfill: tag messages from sub-agent files whose meta sidecar exists + # but whose rows were ingested before agent_type was tracked. Cheap (one UPDATE per file). + for sub_path in parse_claude.CLAUDE_ROOT.glob("*/*/subagents/agent-*.jsonl") if parse_claude.CLAUDE_ROOT.exists() else []: + meta_path = sub_path.with_suffix(".meta.json") + if not meta_path.exists(): + continue + try: + m = json.loads(meta_path.read_text()) + except (json.JSONDecodeError, OSError): + continue + atype, adesc = m.get("agentType"), m.get("description") + aid = sub_path.stem[len("agent-"):] if sub_path.stem.startswith("agent-") else None + if not atype and not adesc and not aid: + continue + conn.execute( + """UPDATE messages SET agent_type=COALESCE(agent_type, ?), + agent_desc=COALESCE(agent_desc, ?), + agent_id=COALESCE(agent_id, ?) + WHERE source_file=? AND (agent_type IS NULL OR agent_desc IS NULL OR agent_id IS NULL)""", + (atype, adesc, aid, str(sub_path)), + ) + for path in parse_claude.discover_files(): files_scanned += 1 updated, ma, mc = _process_file(conn, path, parse_claude) diff --git a/tracker/parse_claude.py b/tracker/parse_claude.py index 333fea3..c3defae 100644 --- a/tracker/parse_claude.py +++ b/tracker/parse_claude.py @@ -34,6 +34,9 @@ class MessageRow: reasoning_tokens: int source_file: str source_line: int + agent_type: str | None = None + agent_desc: str | None = None + agent_id: str | None = None @dataclass @@ -59,6 +62,7 @@ class SessionMeta: model: str | None started_at: str | None ended_at: str | None + entrypoint: str | None = None # 'cli' (interactive REPL), 'sdk-cli' (programmatic), … @dataclass @@ -110,6 +114,20 @@ def _session_uuid_for(path: Path) -> str: return path.stem +def _agent_meta(path: Path) -> tuple[str | None, str | None]: + """For a sub-agent JSONL, read its sibling agent-*.meta.json to get (agentType, description).""" + if path.parent.name != "subagents": + return None, None + meta_path = path.with_suffix(".meta.json") + if not meta_path.exists(): + return None, None + try: + m = json.loads(meta_path.read_text()) + except (json.JSONDecodeError, OSError): + return None, None + return m.get("agentType"), m.get("description") + + def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: """Parse from byte offset; returns (parsed, new_offset). @@ -118,6 +136,9 @@ def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: """ session_uuid = _session_uuid_for(path) session_id = f"claude:{session_uuid}" + agent_type, agent_desc = _agent_meta(path) + # Sub-agent JSONLs are named agent-.jsonl; the id uniquely identifies one invocation. + agent_id = path.stem[len("agent-"):] if path.parent.name == "subagents" and path.stem.startswith("agent-") else None meta = SessionMeta( session_id=session_id, tool="claude", @@ -175,6 +196,9 @@ def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: cwd = d.get("cwd") if cwd and not meta.cwd: meta.cwd = cwd + ep = d.get("entrypoint") + if ep and not meta.entrypoint: + meta.entrypoint = ep if ts: if meta.started_at is None or ts < meta.started_at: meta.started_at = ts @@ -203,6 +227,9 @@ def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: reasoning_tokens=0, source_file=str(path), source_line=line_no, + agent_type=agent_type, + agent_desc=agent_desc, + agent_id=agent_id, ) # Some Claude records put cache_write under cache_creation_input_tokens with no split. if row.cache_write_5m == 0 and row.cache_write_1h == 0: diff --git a/tracker/parse_codex.py b/tracker/parse_codex.py index 46a2fab..3f2e6e2 100644 --- a/tracker/parse_codex.py +++ b/tracker/parse_codex.py @@ -40,6 +40,7 @@ def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: model=None, started_at=None, ended_at=None, + entrypoint="codex", ) parsed = ParsedFile(session=meta) diff --git a/web/app.js b/web/app.js index d5df9e0..bc12248 100644 --- a/web/app.js +++ b/web/app.js @@ -126,7 +126,7 @@ function bucketTooltipTitle(bucket, granularity) { } } -const STATE = { filters: { tool: "", model: "", project: "", start: "", end: "", granularity: "auto" } }; +const STATE = { filters: { tool: "", model: "", project: "", agent: "", entrypoint: "", start: "", end: "", granularity: "auto" } }; let usageChart, costChart, breakdownChart; async function api(path, opts) { @@ -158,6 +158,9 @@ async function loadFilters() { fillSelect($("#f-tool"), d.tools, STATE.filters.tool); fillSelect($("#f-model"), d.models, STATE.filters.model); fillSelect($("#f-project"), d.projects, STATE.filters.project); + // agent dropdown: "main" pseudo-value selects top-level (non-sub-agent) turns only. + fillSelect($("#f-agent"), ["main", ...(d.agents || [])], STATE.filters.agent); + fillSelect($("#f-entrypoint"), d.entrypoints || [], STATE.filters.entrypoint); if (d.date_range.min) { $("#f-start").min = d.date_range.min.slice(0, 10); $("#f-end").max = d.date_range.max.slice(0, 10); @@ -176,22 +179,31 @@ function renderCards(totals) { const freshTokens = tokens_in + tokens_out + tokens_cw5 + tokens_cw1; const allTokens = freshTokens + tokens_hit; const cacheShare = allTokens ? tokens_hit / allTokens : 0; - const cards = [ + const cb = totals.cost_breakdown || {}; + const cwTotal = (cb.cache_write_5m || 0) + (cb.cache_write_1h || 0); + const pct = (v) => cost ? Math.round((v / cost) * 100) + "%" : "—"; + const overview = [ { label: "est cost", value: fmt.usd(cost), accent: true }, { label: "$ / active hour", value: fmt.usd(cph), accent: true, sub: "Σ session spans" }, - { label: "fresh + output tokens", value: fmt.short(freshTokens), sub: fmt.n(freshTokens) }, - { label: "cache share", value: Math.round(cacheShare * 100) + "%", sub: fmt.n(tokens_hit) + " cache hits" }, { label: "sessions", value: fmt.n(totals.sessions) }, { label: "messages", value: fmt.n(totals.msgs) }, { label: "active hours", value: ah < 1 ? ah.toFixed(2) : ah.toFixed(1) }, - { label: "cache writes", value: fmt.short(tokens_cw5 + tokens_cw1), sub: fmt.n(tokens_cw5 + tokens_cw1) }, + { label: "cache share (tok)", value: Math.round(cacheShare * 100) + "%", sub: fmt.short(tokens_hit) + " hits" }, ]; - $("#cards").innerHTML = cards.map(c => ` -
+ const breakdown = [ + { label: "cache read", value: fmt.usd(cb.cache_read), sub: pct(cb.cache_read || 0) + " of total" }, + { label: "cache write", value: fmt.usd(cwTotal), sub: pct(cwTotal) + " · 5m+1h" }, + { label: "output", value: fmt.usd(cb.output), sub: pct(cb.output || 0) + " · incl reasoning" }, + { label: "fresh input", value: fmt.usd(cb.input), sub: pct(cb.input || 0) + " of total" }, + ]; + const renderCard = (c) => ` +
${c.label}
${c.value}
${c.sub ? `
${c.sub}
` : ""} -
`).join(""); +
`; + $("#cards-overview").innerHTML = overview.map(renderCard).join(""); + $("#cards-breakdown").innerHTML = breakdown.map(renderCard).join(""); } const chartFontColor = "#9aa1ad"; @@ -317,6 +329,7 @@ function labelForGroupRow(group, r) { if (group === "tool") return r.tool; if (group === "model") return r.model; if (group === "project") return fmt.pathTail(r.project, 32); + if (group === "agent") return r.agent; if (group === "session") return `${r.tool} · ${fmt.pathTail(r.cwd || r.id, 28)}`; if (group === "server") return r.server; if (group === "mcp_tool") return `${r.server} · ${r.tool_name}`; @@ -465,8 +478,32 @@ const BREAKDOWN_COLS = { { key: "cache_write_1h", label: "write 1h", num: true, fmt: fmt.n }, { key: "cost_usd", label: "cost", num: true, fmt: fmt.usd, css: "cost" }, ], + entrypoint: [ + { key: "entrypoint", label: "entrypoint" }, + { key: "sessions", label: "sessions", num: true, fmt: fmt.n }, + { key: "msgs", label: "msgs", num: true, fmt: fmt.n }, + { key: "input_tokens", label: "input", num: true, fmt: fmt.n }, + { key: "output_tokens", label: "output", num: true, fmt: fmt.n }, + { key: "cache_hit", label: "cache hits", num: true, fmt: fmt.n }, + { key: "cache_write_5m", label: "write 5m", num: true, fmt: fmt.n }, + { key: "cache_write_1h", label: "write 1h", num: true, fmt: fmt.n }, + { key: "cost_usd", label: "cost", num: true, fmt: fmt.usd, css: "cost" }, + ], + agent: [ + { key: "agent_type", label: "agent" }, + { key: "agent_id", label: "id", css: "dim", fmt: (v) => v ? v.slice(0, 8) : "—" }, + { key: "agent_desc", label: "task", fmt: (v) => v ? fmt.pathTail(v, 60) : "—" }, + { key: "msgs", label: "msgs", num: true, fmt: fmt.n }, + { key: "input_tokens", label: "input", num: true, fmt: fmt.n }, + { key: "output_tokens", label: "output", num: true, fmt: fmt.n }, + { key: "cache_hit", label: "cache hits", num: true, fmt: fmt.n }, + { key: "cache_write_5m", label: "write 5m", num: true, fmt: fmt.n }, + { key: "cache_write_1h", label: "write 1h", num: true, fmt: fmt.n }, + { key: "cost_usd", label: "cost", num: true, fmt: fmt.usd, css: "cost" }, + ], session: [ { key: "tool", label: "tool" }, + { key: "entrypoint", label: "entrypoint", css: "dim", fmt: (v) => v || "—" }, { key: "model", label: "model", css: "dim" }, { key: "cwd", label: "project", fmt: (v) => fmt.pathTail(v, 50) }, { key: "msg_count", label: "msgs", num: true, fmt: fmt.n }, @@ -507,11 +544,17 @@ async function loadBreakdown() { let rows = []; let clickFn = null; - if (g === "tool" || g === "model" || g === "project") { + if (g === "tool" || g === "model" || g === "project" || g === "agent" || g === "entrypoint") { const d = STATE.statsCache || await api("/api/stats" + queryString()); - rows = g === "tool" ? d.by_tool : g === "model" ? d.by_model : d.by_project; + rows = g === "tool" ? d.by_tool + : g === "model" ? d.by_model + : g === "project" ? d.by_project + : g === "entrypoint" ? d.by_entrypoint + : d.by_agent; ctrls.innerHTML = ""; - hint.textContent = ""; + hint.textContent = g === "agent" ? "one row per sub-agent invocation; 'main session' aggregates all top-level turns" + : g === "entrypoint" ? "'cli' = interactive REPL; 'sdk-cli' = spawned via the Claude Code SDK; 'codex' = OpenAI CLI" + : ""; } else if (g === "session") { const sort = STATE.sessSort || "cost"; const d = await api("/api/sessions" + queryString({ sort, limit: 200 })); @@ -630,6 +673,12 @@ async function showSession(id) { const mtable = `${tableHTML([ { key: "ts", label: "time", fmt: fmt.date, css: "dim" }, { key: "model", label: "model", css: "dim" }, + { key: "agent_type", label: "agent", css: "dim", fmt: (v, r) => { + if (!v) return "main"; + const id = r.agent_id ? ` #${r.agent_id.slice(0,8)}` : ""; + const desc = r.agent_desc ? ` · ${fmt.pathTail(r.agent_desc, 36)}` : ""; + return `${v}${id}${desc}`; + } }, { key: "input_tokens", label: "input", num: true, fmt: fmt.n }, { key: "output_tokens", label: "output", num: true, fmt: fmt.n }, { key: "cache_read", label: "cache hit (read)", num: true, fmt: fmt.n }, @@ -666,6 +715,8 @@ function readFilters() { STATE.filters.tool = $("#f-tool").value; STATE.filters.model = $("#f-model").value; STATE.filters.project = $("#f-project").value; + STATE.filters.agent = $("#f-agent").value; + STATE.filters.entrypoint = $("#f-entrypoint").value; // start/end may already be ISO strings from quick-range presets; fall back to the date inputs. if (!STATE.filters._rangePreset) { STATE.filters.start = $("#f-start").value ? $("#f-start").value + "T00:00:00Z" : ""; @@ -727,6 +778,8 @@ document.addEventListener("DOMContentLoaded", async () => { $("#apply").addEventListener("click", refresh); $("#reset").addEventListener("click", () => { $("#f-tool").value = ""; $("#f-model").value = ""; $("#f-project").value = ""; + $("#f-agent").value = ""; + $("#f-entrypoint").value = ""; $("#f-start").value = ""; $("#f-end").value = ""; $("#f-granularity").value = "auto"; $$(".btn.range").forEach(b => b.classList.remove("active")); diff --git a/web/index.html b/web/index.html index 14ec2f2..1416874 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,12 @@ + + @@ -44,7 +50,14 @@ -
+
+
overview
+
+
+
+
where the $ goes
+
+
@@ -74,6 +87,8 @@

breakdown

+ + diff --git a/web/styles.css b/web/styles.css index 755885f..5e7ecc9 100644 --- a/web/styles.css +++ b/web/styles.css @@ -69,15 +69,32 @@ header { gap: 1px; background: var(--border); margin: 0; +} +/* --locked: never wrap; cards shrink uniformly to fit one row. */ +.cards--locked { + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + grid-template-columns: none; +} +.card-group { border-bottom: 1px solid var(--border); + background: var(--panel); +} +.card-group-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-mute); + padding: 8px 14px 0; } .card { background: var(--panel); - padding: 14px 18px; + padding: 10px 14px; + min-width: 0; /* let grid shrink below content width */ } -.card .label { color: var(--text-mute); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; } -.card .value { font-size: 20px; margin-top: 4px; color: var(--text); } -.card .sub { color: var(--text-dim); font-size: 11px; margin-top: 2px; } +.card .label { color: var(--text-mute); font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.card .value { font-size: 17px; margin-top: 2px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.card .sub { color: var(--text-dim); font-size: 10px; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card.accent .value { color: var(--accent-2); } .panel {