Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **OpenAI Codex CLI support.** `agentdiff` now reads Codex rollouts from
`~/.codex/sessions` and `archived_sessions` (or `$CODEX_HOME`), alongside
Claude Code, and runs the same findings on them. Usage comes from
`token_usage_record` (one per API response, de-duplicated by response id),
falling back to `token_count` events in older logs. Each turn is priced with
the model from its `turn_context`, including OpenAI's long-context tier
above 272K tokens and the Fast tier. Tool calls pair with their outputs, and
exit codes are read from the output text or from paginated
`item_completed` events. Legacy forks that copy the parent's usage lines are
counted once, and subagent threads nest under their parent. Compressed
`.jsonl.zst` rollouts are read when a zstd module is available.
- New `--agent all|claude|codex` and `--codex-home` options. The report header
names the agents it read, adds an Agent column when there is more than one,
and notes that Codex figures are API-equivalent estimates.
- OpenAI model prices (gpt-6.x, gpt-5.x, codex, o3/o4-mini) at list rates as
of 2026-10-19.

### Changed
- Fork de-duplication moved to a shared module and applies to every agent.
- Requests on models with no known price are now called out in the report
footer instead of silently counting as $0.

## [0.6.2] - 2026-10-19

AgentDiff v0.6.2 ships the npm launcher under its working name:
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ or `uvx --from agent-trajectory-diff agentdiff` · or `/agentdiff:report` in [Cl

</div>

AgentDiff reads the session logs Claude Code already keeps on your machine and
tells you **where the money went and what was avoidable**. No config, no API
AgentDiff reads the session logs Claude Code and the OpenAI Codex CLI already
keep on your machine and tells you **where the money went and what was avoidable**. No config, no API
key, no account. Nothing leaves your machine.

```text
Expand Down Expand Up @@ -82,7 +82,9 @@ agentdiff --since 30d # 24h, 7d, 30d, 12w … or all
agentdiff --project api # only projects whose name contains "api"
agentdiff --top 10 # list more findings and sessions
agentdiff --json > spend.json # machine-readable
agentdiff --agent codex # only Codex (or: claude, all)
agentdiff --path /other/.claude # a different Claude Code config dir
agentdiff --codex-home /other/.codex # a different Codex home
```

## Install
Expand Down Expand Up @@ -131,7 +133,14 @@ Without the plugin, prefix the command with `!` to run it in a session:
## How the numbers are computed

- **Source:** `~/.claude/projects/**/*.jsonl` (or `$CLAUDE_CONFIG_DIR`),
including subagent transcripts.
including subagent transcripts, and Codex's
`~/.codex/sessions/**/rollout-*.jsonl` plus `archived_sessions/` (or
`$CODEX_HOME`), including subagent threads.
- **Codex:** usage is read per API response (`token_usage_record`, or
`token_count` in older logs) and priced at OpenAI list prices for the model
active in each turn, including the long-context tier above 272K tokens and
the Fast tier. Codex logs carry no cost of their own, so these are
API-equivalent estimates (on a ChatGPT plan you aren't billed per token).
- **Counted once:** Claude Code repeats usage on every content block of a
request, and resumed or forked sessions copy their parent's history. AgentDiff
counts each billed request exactly once.
Expand All @@ -153,7 +162,7 @@ network calls and has no telemetry.
| Agent | Status |
| :--- | :--- |
| Claude Code | ✅ |
| OpenAI Codex CLI | Next |
| OpenAI Codex CLI | ✅ (new in 0.7) |
| Gemini CLI, Cursor | Planned |

---
Expand Down
26 changes: 21 additions & 5 deletions src/agentdiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,23 +322,39 @@ def sessions(
),
top: int = typer.Option(5, "--top", help="How many findings and sessions to list."),
as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
agent: str = typer.Option(
"all", "--agent", help="Which agent's logs to read: all, claude or codex."
),
path: str | None = typer.Option(
None,
"--path",
help="Claude Code config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude).",
),
codex_home: str | None = typer.Option(
None,
"--codex-home",
help="Codex CLI home dir (default: $CODEX_HOME or ~/.codex).",
),
):
"""Explain where your Claude Code spend went and what was wasted.
"""Explain where your coding-agent spend went and what was wasted.

Reads the session logs Claude Code already keeps on this machine. Nothing
is uploaded. Sessions are included when they were active in the period.
Reads the session logs Claude Code and the OpenAI Codex CLI already keep
on this machine. Nothing is uploaded. Sessions are included when they were
active in the period.
"""
from agentdiff.sessions.analyzer import analyze
from agentdiff.sessions.claude_code import load_sessions
from agentdiff.sessions.loader import load_sessions
from agentdiff.sessions.report import render, to_json

agent = agent.strip().lower()
if agent not in ("all", "claude", "codex"):
raise typer.BadParameter("use all, claude or codex", param_hint="--agent")
cutoff = _parse_since(since)
found = load_sessions(Path(path).expanduser() if path else None)
found = load_sessions(
agent,
claude_root=Path(path).expanduser() if path else None,
codex_home=Path(codex_home).expanduser() if codex_home else None,
)
if cutoff is not None:
found = [s for s in found if s.ended_at and s.ended_at >= cutoff]
if project:
Expand Down
22 changes: 18 additions & 4 deletions src/agentdiff/sessions/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from agentdiff.sessions.pricing import price_for

READ_TOOLS = {"Read", "Grep", "Glob", "LS", "NotebookRead"}
# Claude Code uses Bash; Codex CLI uses exec_command / shell / local_shell.
SHELL_TOOLS = {"Bash", "shell", "exec_command", "local_shell"}

# Context above this size is re-sent every turn; long tails here are what
# /clear or /compact between tasks would avoid.
Expand Down Expand Up @@ -89,11 +91,14 @@ def describe_call(call: ToolCall, width: int = 70) -> str:
"notebook_path",
"path",
"command",
"cmd",
"pattern",
"url",
"query",
):
value = data.get(key)
if isinstance(value, list) and value and all(isinstance(v, str) for v in value):
value = " ".join(value)
if isinstance(value, str) and value.strip():
text = " ".join(value.split())
return text if len(text) <= width else text[: width - 1] + "…"
Expand All @@ -107,17 +112,26 @@ def describe_call(call: ToolCall, width: int = 70) -> str:


def _command_family(call: ToolCall) -> str:
"""Groups Bash commands by their leading words (e.g. ``npm test``)."""
command = call.input.get("command")
if call.name != "Bash" or not isinstance(command, str):
"""Groups shell commands by their leading words (e.g. ``npm test``)."""
command = call.input.get("command") or call.input.get("cmd")
if isinstance(command, list) and all(isinstance(c, str) for c in command):
command = " ".join(command)
if call.name not in SHELL_TOOLS or not isinstance(command, str):
return f"{call.name}:{describe_call(call, 200)}"
words = command.strip().split()
# Skip env assignments and `cd x &&` prefixes.
while words and ("=" in words[0] and not words[0].startswith("-")):
words = words[1:]
if len(words) >= 3 and words[0] == "cd" and words[2] in ("&&", ";"):
words = words[3:]
return "Bash:" + " ".join(words[:2])
# Codex wraps commands as `bash -lc '<cmd>'`; look inside.
if (
len(words) >= 3
and words[0] in ("bash", "sh", "zsh")
and words[1] in ("-lc", "-c")
):
words = " ".join(words[2:]).strip("'\"").split()
return "shell:" + " ".join(words[:2])


def analyze_session(session: Session, t: Thresholds | None = None) -> list[Finding]:
Expand Down
51 changes: 3 additions & 48 deletions src/agentdiff/sessions/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
from pathlib import Path
from typing import Any

from agentdiff.sessions.dedupe import dedupe_forks
from agentdiff.sessions.models import ModelRequest, Session, ToolCall

SOURCE = "claude_code"
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)

__all__ = ["dedupe_forks", "discover", "load_sessions", "read_session"]


def default_root() -> Path:
"""Claude Code's config dir, honouring ``CLAUDE_CONFIG_DIR``."""
Expand All @@ -49,54 +52,6 @@ def load_sessions(root: Path | None = None) -> list[Session]:
return dedupe_forks(sessions)


def dedupe_forks(sessions: list[Session]) -> list[Session]:
"""Counts each billed request once across sessions.

Resumed and forked sessions copy the parent's history (same request ids)
into a new file. The earliest session keeps a request; later copies drop
it. A fork's own ``cost-state`` includes inherited spend, so it is not
trusted once anything was removed.
"""
seen: set[str] = set()
kept = []
for session in sorted(sessions, key=_age_key):
if _drop_seen(session, seen):
session.reported_cost = None
if session.all_requests():
kept.append(session)
return kept


def _age_key(session: Session) -> tuple[datetime, float]:
# Copied history carries the parent's timestamps, so break ties by which
# file was created first.
try:
stat = os.stat(session.path)
created = getattr(stat, "st_birthtime", stat.st_mtime)
except OSError:
created = 0.0
return (session.started_at or _EPOCH, created)


def _drop_seen(session: Session, seen: set[str]) -> bool:
removed = False
fresh = []
for request in session.requests:
if request.request_id in seen:
removed = True
continue
seen.add(request.request_id)
fresh.append(request)
if removed:
live = {cid for r in fresh for cid in r.tool_call_ids}
session.requests = fresh
session.tool_calls = [c for c in session.tool_calls if c.call_id in live]
for sub in session.subagents:
removed = _drop_seen(sub, seen) or removed
session.subagents = [s for s in session.subagents if s.requests]
return removed


def read_session(path: Path) -> Session | None:
"""Parses one session file plus its subagent transcripts."""
session = _parse_file(path)
Expand Down
Loading
Loading