Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ Costs are calculated using **Anthropic API pricing as of April 2026** ([claude.c

---

## Cowork sessions

Claude Desktop (the agent / Cowork mode in the desktop app) writes a per-session
audit log to its userData directory:

| OS | Path |
|---|---|
| macOS | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
| Windows | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Linux | `$XDG_CONFIG_HOME/Claude/local-agent-mode-sessions/` (default `~/.config/...`) |

`scan` automatically picks these up alongside `~/.claude/projects/`. Sessions
appear in the dashboard under project names like `Cowork/<8-char-id>`.

Token totals come from the authoritative `result.modelUsage` blocks (the same
numbers Anthropic uses for billing), so cost estimates line up with what
the API reports rather than aggregating per-event streaming chunks.

## Files

| File | Purpose |
Expand Down
131 changes: 131 additions & 0 deletions cowork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Cowork (Claude Desktop "agent" / Cowork mode) session log support.

Claude Desktop writes one JSONL audit log per Cowork session to its userData
directory. Each `result` event in the log carries an authoritative `modelUsage`
breakdown — the same numbers Anthropic uses for billing — so we synthesize one
turn per (result, model) pair and let the rest of the pipeline (aggregation,
pricing, dashboard) treat them like any other Claude Code turns.

Why we don't read the per-event `assistant` records: they mix per-event
input/output tokens with cumulative cache numbers, and some streaming chunks
are duplicated. Naive aggregation undercounts output tokens by ~20x. The
`result` events have already done the bookkeeping correctly.
"""

import json
import os
import sys
from pathlib import Path


def cowork_sessions_dir():
"""Directory where Claude Desktop writes per-session audit.jsonl files.

Returns the platform-specific Electron userData path joined with
"local-agent-mode-sessions". Returns None on platforms where we can't
determine the path (e.g. Windows without %APPDATA%).
"""
if sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support" / "Claude"
elif sys.platform == "win32":
appdata = os.environ.get("APPDATA")
if not appdata:
return None
base = Path(appdata) / "Claude"
else: # Linux/BSD — Electron uses XDG_CONFIG_HOME (or ~/.config)
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
base = Path(xdg) / "Claude"
return base / "local-agent-mode-sessions"


def find_audit_files(base_dir=None):
"""Return all audit.jsonl files under base_dir (default: cowork_sessions_dir())."""
base = Path(base_dir) if base_dir else cowork_sessions_dir()
if not base or not base.exists():
return []
return sorted(base.rglob("audit.jsonl"))


def _normalise_model(name):
"""Cowork sometimes appends a tier hint like "[1m]" for 1-hour cache.
Strip it so the dashboard's pricing lookup matches a known model name."""
if not name:
return name
return name.split("[", 1)[0]


def parse_audit_file(filepath):
"""Parse one Cowork audit.jsonl into the same shape as parse_jsonl_file().

Returns (session_metas, turns, line_count). The contract matches
scanner.parse_jsonl_file() so the scan loop can dispatch by filename.
"""
session_meta = {}
turns = []
line_count = 0
msg_idx = 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")
if not session_id:
continue

timestamp = record.get("_audit_timestamp", "")
project_name = f"Cowork/{session_id[:8]}"

if session_id not in session_meta:
session_meta[session_id] = {
"session_id": session_id,
"project_name": project_name,
"first_timestamp": timestamp,
"last_timestamp": timestamp,
"git_branch": "",
"model": None,
}
else:
meta = session_meta[session_id]
if timestamp:
if not meta["first_timestamp"] or timestamp < meta["first_timestamp"]:
meta["first_timestamp"] = timestamp
if not meta["last_timestamp"] or timestamp > meta["last_timestamp"]:
meta["last_timestamp"] = timestamp

model_usage = record.get("modelUsage") or {}
for model_raw, usage in model_usage.items():
model = _normalise_model(model_raw)
msg_idx += 1
turns.append({
"session_id": session_id,
"timestamp": timestamp,
"model": model,
"input_tokens": int(usage.get("inputTokens", 0) or 0),
"output_tokens": int(usage.get("outputTokens", 0) or 0),
"cache_read_tokens": int(usage.get("cacheReadInputTokens", 0) or 0),
"cache_creation_tokens": int(usage.get("cacheCreationInputTokens", 0) or 0),
"tool_name": None,
"cwd": project_name,
"message_id": f"cowork-{session_id}-{msg_idx}-{model}",
})
except FileNotFoundError:
pass

return list(session_meta.values()), turns, line_count


def is_audit_file(filepath):
"""True if filepath looks like a Cowork audit.jsonl."""
return Path(filepath).name == "audit.jsonl"
39 changes: 35 additions & 4 deletions scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -332,6 +334,17 @@ 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))

# Cowork desktop-app session logs (multi-platform path detection in cowork.py).
# Only included on a "default" scan (no explicit projects_dir / projects_dirs);
# if a caller passed an override, treat it as "scan exactly this and nothing else".
if not projects_dir and not projects_dirs:
cowork_dir = cowork.cowork_sessions_dir()
if cowork_dir and cowork_dir.exists():
if verbose:
print(f"Scanning {cowork_dir} (Cowork) ...")
jsonl_files.extend(str(p) for p in cowork.find_audit_files(cowork_dir))

jsonl_files.sort()

new_files = 0
Expand Down Expand Up @@ -360,9 +373,24 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True):
status = "NEW" if is_new else "UPD"
print(f" [{status}] {filepath}")

if is_new:
# New file: full parse (single read, returns line count)
session_metas, turns, line_count = parse_jsonl_file(filepath)
is_cowork = cowork.is_audit_file(filepath)
if is_new or is_cowork:
# New (or any Cowork) file: full parse. Cowork audit logs always
# take this path because their schema doesn't lend itself to
# incremental line-by-line updates — `result` events carry
# cumulative per-session totals, not deltas, so reparsing is
# easier and still cheap (these files are small).
if is_cowork:
session_metas, turns, line_count = cowork.parse_audit_file(filepath)
else:
session_metas, turns, line_count = parse_jsonl_file(filepath)

if is_cowork and not is_new:
# Replace existing turns/sessions for these IDs so totals
# don't double-count after a Cowork rescan.
for sm in session_metas:
conn.execute("DELETE FROM turns WHERE session_id = ?",
(sm["session_id"],))

if turns or session_metas:
sessions = aggregate_sessions(session_metas, turns)
Expand All @@ -371,7 +399,10 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True):
for s in sessions:
total_sessions.add(s["session_id"])
total_turns += len(turns)
new_files += 1
if is_new:
new_files += 1
else:
updated_files += 1

else:
# Updated file: read once, process only new lines
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/cowork/local_abc12345/audit.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"type": "user", "session_id": "abc12345-0000-0000-0000-000000000000", "_audit_timestamp": "2026-04-25T12:00:00.000Z", "message": {"role": "user", "content": "hi"}}
{"type": "assistant", "session_id": "abc12345-0000-0000-0000-000000000000", "_audit_timestamp": "2026-04-25T12:00:01.000Z", "message": {"id": "msg_streaming_1", "model": "claude-opus-4-7", "usage": {"input_tokens": 999, "output_tokens": 999, "cache_read_input_tokens": 999, "cache_creation_input_tokens": 999}}}
{"type": "assistant", "session_id": "abc12345-0000-0000-0000-000000000000", "_audit_timestamp": "2026-04-25T12:00:02.000Z", "message": {"id": "msg_streaming_2", "model": "claude-opus-4-7", "usage": {"input_tokens": 999, "output_tokens": 999, "cache_read_input_tokens": 999, "cache_creation_input_tokens": 999}}}
{"type": "result", "subtype": "success", "session_id": "abc12345-0000-0000-0000-000000000000", "_audit_timestamp": "2026-04-25T12:00:03.000Z", "total_cost_usd": 0.42, "modelUsage": {"claude-opus-4-7": {"inputTokens": 100, "outputTokens": 200, "cacheReadInputTokens": 1000, "cacheCreationInputTokens": 50, "costUSD": 0.4}, "claude-haiku-4-5[1m]": {"inputTokens": 10, "outputTokens": 5, "cacheReadInputTokens": 100, "cacheCreationInputTokens": 20, "costUSD": 0.02}}}
{"type": "result", "subtype": "success", "session_id": "abc12345-0000-0000-0000-000000000000", "_audit_timestamp": "2026-04-25T12:05:00.000Z", "total_cost_usd": 0.08, "modelUsage": {"claude-opus-4-7": {"inputTokens": 50, "outputTokens": 100, "cacheReadInputTokens": 500, "cacheCreationInputTokens": 25}}}
146 changes: 146 additions & 0 deletions tests/test_cowork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Tests for the Cowork audit-log support."""
import os
import sys
import unittest
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import cowork


FIXTURE = Path(__file__).resolve().parent / "fixtures" / "cowork"
SAMPLE = FIXTURE / "local_abc12345" / "audit.jsonl"


class TestCoworkSessionsDir(unittest.TestCase):
"""Multi-platform path detection."""

def test_macos(self):
with mock.patch("cowork.sys.platform", "darwin"):
p = cowork.cowork_sessions_dir()
self.assertIsNotNone(p)
# Use Path-internal parts so the test passes on any OS the suite
# is run on (CI normally Linux).
parts = p.parts
self.assertIn("Library", parts)
self.assertIn("Application Support", parts)
self.assertIn("Claude", parts)
self.assertEqual(parts[-1], "local-agent-mode-sessions")

def test_windows(self):
fake_appdata = "C:/Users/test/AppData/Roaming"
with mock.patch("cowork.sys.platform", "win32"), \
mock.patch.dict(os.environ, {"APPDATA": fake_appdata}, clear=False):
p = cowork.cowork_sessions_dir()
self.assertIsNotNone(p)
self.assertIn("Claude", p.parts)
self.assertEqual(p.parts[-1], "local-agent-mode-sessions")

def test_windows_without_appdata_returns_none(self):
env = {k: v for k, v in os.environ.items() if k != "APPDATA"}
with mock.patch("cowork.sys.platform", "win32"), \
mock.patch.dict(os.environ, env, clear=True):
self.assertIsNone(cowork.cowork_sessions_dir())

def test_linux_xdg(self):
with mock.patch("cowork.sys.platform", "linux"), \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg"}, clear=False):
p = cowork.cowork_sessions_dir()
self.assertEqual(str(p), "/tmp/xdg/Claude/local-agent-mode-sessions")

def test_linux_default(self):
env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"}
with mock.patch("cowork.sys.platform", "linux"), \
mock.patch.dict(os.environ, env, clear=True):
p = cowork.cowork_sessions_dir()
self.assertTrue(str(p).endswith("/.config/Claude/local-agent-mode-sessions"))


class TestFindAuditFiles(unittest.TestCase):
def test_finds_fixture(self):
files = cowork.find_audit_files(FIXTURE)
self.assertEqual(len(files), 1)
self.assertEqual(files[0].name, "audit.jsonl")

def test_missing_dir_returns_empty(self):
self.assertEqual(cowork.find_audit_files("/nonexistent/path/xyzzy"), [])


class TestParseAuditFile(unittest.TestCase):
"""Parser correctness against the fixture."""

def setUp(self):
self.session_metas, self.turns, self.line_count = cowork.parse_audit_file(SAMPLE)

def test_one_session_emitted(self):
self.assertEqual(len(self.session_metas), 1)
meta = self.session_metas[0]
self.assertEqual(meta["session_id"], "abc12345-0000-0000-0000-000000000000")
self.assertEqual(meta["project_name"], "Cowork/abc12345")

def test_session_timestamp_window(self):
meta = self.session_metas[0]
self.assertEqual(meta["first_timestamp"], "2026-04-25T12:00:03.000Z")
self.assertEqual(meta["last_timestamp"], "2026-04-25T12:05:00.000Z")

def test_one_turn_per_result_per_model(self):
# Two result events; first has 2 models, second has 1 -> 3 turns.
self.assertEqual(len(self.turns), 3)

def test_assistant_streaming_records_ignored(self):
# The fixture's per-event assistant records claim 999 tokens each.
# If the parser was reading them, we'd see those numbers. Verify
# we don't.
for t in self.turns:
self.assertNotEqual(t["input_tokens"], 999)
self.assertNotEqual(t["output_tokens"], 999)

def test_authoritative_totals(self):
# Sum across all turns should match the result.modelUsage totals.
total_in = sum(t["input_tokens"] for t in self.turns)
total_out = sum(t["output_tokens"] for t in self.turns)
total_cr = sum(t["cache_read_tokens"] for t in self.turns)
total_cw = sum(t["cache_creation_tokens"] for t in self.turns)
self.assertEqual(total_in, 100 + 10 + 50)
self.assertEqual(total_out, 200 + 5 + 100)
self.assertEqual(total_cr, 1000 + 100 + 500)
self.assertEqual(total_cw, 50 + 20 + 25)

def test_tier_suffix_normalised(self):
models = {t["model"] for t in self.turns}
self.assertIn("claude-opus-4-7", models)
# "claude-haiku-4-5[1m]" -> "claude-haiku-4-5"
self.assertIn("claude-haiku-4-5", models)
for m in models:
self.assertNotIn("[", m)

def test_unique_message_ids(self):
# Each synthetic turn must have a unique message_id so the upstream
# scanner's last-wins dedup keeps every row.
ids = [t["message_id"] for t in self.turns]
self.assertEqual(len(ids), len(set(ids)))

def test_cwd_set_to_project_name(self):
for t in self.turns:
self.assertEqual(t["cwd"], "Cowork/abc12345")

def test_skips_records_without_session_id(self):
# If we crafted a result event without session_id, it must be skipped.
# Easiest: verify the parser doesn't crash on missing keys
# (already tested by fixture loading without errors).
self.assertGreater(self.line_count, 0)


class TestIsAuditFile(unittest.TestCase):
def test_audit_jsonl_recognised(self):
self.assertTrue(cowork.is_audit_file("/some/path/audit.jsonl"))
self.assertTrue(cowork.is_audit_file(Path("/p/audit.jsonl")))

def test_other_jsonl_not_recognised(self):
self.assertFalse(cowork.is_audit_file("/.claude/projects/foo/abc.jsonl"))


if __name__ == "__main__":
unittest.main()