From 2fda7a857260b930606f6e1cfcad2774198da152 Mon Sep 17 00:00:00 2001 From: Ludwig Ehlert Date: Wed, 20 May 2026 13:50:32 +0200 Subject: [PATCH] Ingest Claude Code sub-agent sessions; fix model filter on MCP views Sub-agent (Task tool) turns are written to ~/.claude/projects///subagents/agent-*.jsonl and were never discovered by the ingester, so usage from spawned agents (often claude-haiku-4-5) was invisible in totals, charts, and breakdowns. The parser now globs those files and rolls their messages into the parent session. The /api/mcp endpoint was missing the `model` query parameter entirely, so the model filter was silently dropped on the "by mcp server" and "by mcp tool" breakdowns. /api/breakdown_series did accept it but filtered on sessions.model (last observed), which is wrong when a session uses multiple models. Both now filter per-message via the assistant turn that issued the call (matching mcp_calls.source_file + source_line to messages.source_file + source_line), consistent with how /api/stats already filters. Co-Authored-By: Claude Opus 4.7 (1M context) --- tracker/api.py | 10 +++++++++- tracker/parse_claude.py | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tracker/api.py b/tracker/api.py index 1071411..d3edbd8 100644 --- a/tracker/api.py +++ b/tracker/api.py @@ -259,7 +259,9 @@ def breakdown_series( if tool: clauses.append("mc.tool = ?"); params.append(tool) if model: - clauses.append("s.model = ?"); params.append(model) + 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 project: clauses.append("s.cwd = ?"); params.append(project) if start: @@ -490,6 +492,7 @@ def _per_call_lifecycle_cost(tokens: int, tool: str, model: str | None, subseque @app.get("/api/mcp") def mcp( 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), @@ -501,6 +504,11 @@ def mcp( params: list = [] if tool: clauses.append("mc.tool = ?"); params.append(tool) + if model: + # An MCP call is issued by the assistant message at the same source_file/source_line. + 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 project: clauses.append("s.cwd = ?"); params.append(project) if start: diff --git a/tracker/parse_claude.py b/tracker/parse_claude.py index 6e1d1fd..333fea3 100644 --- a/tracker/parse_claude.py +++ b/tracker/parse_claude.py @@ -101,13 +101,22 @@ def _tool_result_chars(content) -> tuple[int, int]: return len(json.dumps(content, default=str)), 0 +def _session_uuid_for(path: Path) -> str: + """Sub-agent JSONLs live at //subagents/agent-*.jsonl + and must roll up into the parent session. Top-level files use their own stem. + """ + if path.parent.name == "subagents": + return path.parent.parent.name + return path.stem + + def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: """Parse from byte offset; returns (parsed, new_offset). new_offset is the byte position AFTER the last fully-parsed line. If a trailing partial line exists, we stop before it so the next run picks it up. """ - session_uuid = path.stem + session_uuid = _session_uuid_for(path) session_id = f"claude:{session_uuid}" meta = SessionMeta( session_id=session_id, @@ -244,4 +253,7 @@ def parse_file(path: Path, *, start_offset: int = 0) -> tuple[ParsedFile, int]: def discover_files() -> list[Path]: if not CLAUDE_ROOT.exists(): return [] - return sorted(CLAUDE_ROOT.glob("*/*.jsonl")) + top = CLAUDE_ROOT.glob("*/*.jsonl") + # Sub-agent (Task tool) sessions are written to //subagents/agent-*.jsonl. + sub = CLAUDE_ROOT.glob("*/*/subagents/agent-*.jsonl") + return sorted({*top, *sub})