cctrack 0.8.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cctrack-0.8.0 → cctrack-0.9.0}/CLAUDE.md +4 -4
- {cctrack-0.8.0 → cctrack-0.9.0}/PKG-INFO +16 -4
- {cctrack-0.8.0 → cctrack-0.9.0}/README.md +15 -3
- {cctrack-0.8.0 → cctrack-0.9.0}/pyproject.toml +1 -1
- {cctrack-0.8.0 → cctrack-0.9.0}/src/cctrack/__init__.py +205 -37
- cctrack-0.9.0/tests/conftest.py +25 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/tests/test_hook_reporting.py +21 -13
- cctrack-0.9.0/tests/test_sandy.py +298 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/.github/workflows/ci.yml +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/.github/workflows/publish.yml +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/.gitignore +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/LICENSE +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/scripts/diagnose_session_cost.py +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/scripts/trace_project.py +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/src/cctrack/__main__.py +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/tests/test_cctrack.py +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/tests/test_codex.py +0 -0
- {cctrack-0.8.0 → cctrack-0.9.0}/tests/test_hook.py +0 -0
|
@@ -33,13 +33,13 @@ This is a single-module CLI tool. All logic lives in `src/cctrack/__init__.py`:
|
|
|
33
33
|
|
|
34
34
|
1. **Rate card** (`RATES`, `_an`/`_oa` row builders, `_family_matches`, `get_rates`, `calculate_cost`, `is_known_model`) - Anthropic and OpenAI prices. **Every model version needs its own row**: `_family_matches` lets a row absorb only the exact id, dated snapshots (`-20250514`, `-2025-08-07`) and non-version suffixes (`-codex`) — never a short-number minor version (`claude-opus-5` does NOT match `claude-opus-5-5`) or a size-tier word (`mini`, `nano`, `pro`, `max`, `luna`, `terra`, `sol`, `astra`). This is deliberate: prefix inheritance silently mispriced opus-4-8 (3×) and opus-5-5 (84%) before. Unknown models are flagged `?` and fall back to Sonnet (Claude) or GPT-5 (OpenAI) rates. `get_rates` takes the usage date (time-limited `intro` pricing) and the request's prompt size (`long_context` tiers: Haiku 5.5 >100K, several OpenAI models >272K). Re-verify against the official pricing pages when adding rows; don't infer a price from a neighbouring model.
|
|
35
35
|
1a. **Energy model** (`ENERGY_WH_PER_MTOK`, `estimate_energy_wh`) - Opt-in (`--energy`) order-of-magnitude estimate keyed off each rate entry's `tier`. Deliberately NOT a function of price: both cache-write TTLs use one figure because a 1h write costs more money but the same compute.
|
|
36
|
-
2. **File discovery** (`default_dirs`, `discover_files`) -
|
|
37
|
-
2a. **Codex** (`discover_codex_files`, `parse_codex_lines`, `parse_codex_events`, `fetch_remote_codex`) - Reads `$CODEX_HOME` (default `~/.codex`) `sessions/` + `archived_sessions/` rollouts, incl. `.jsonl.zst` (stdlib `compression.zstd` on 3.14+, else the `zstd` CLI, else skipped and counted). Lines are `{timestamp, type, payload}`. Prefer `token_usage_record` items (per-response, dedup by `response_id` — forks replay parent history); fall back to legacy `event_msg`/`token_count` only before a file's first record, counting `last_token_usage` only when `total_token_usage.total_tokens` moved (it's re-emitted unchanged). **`input_tokens` includes `cached_input_tokens` and `cache_write_input_tokens`; `output_tokens` includes reasoning** — uncached input = input − cached − cache_write. Model/cwd from the latest `turn_context`; thread id + git branch from `session_meta`. Codex events carry `agent="codex"`.
|
|
38
|
-
3. **Remote aggregation** (`fetch_remote_jsonl`, `parse_remote_events`, `fetch_remote_hook_data`, `parse_remote_hook_data`) -
|
|
36
|
+
2. **File discovery** (`sandy_home`, `_sandy_print_state`, `sandbox_roots`, `default_dirs`, `discover_files`) - Claude transcripts from `~/.claude/projects/` plus each Sandy sandbox's `claude/projects/` (ONLY `projects/` — `claude/` also holds `history.jsonl`, job timelines, tool-audit logs). Sandbox roots come from `sandy --print-state` → `sandboxes[].path` (follows `$SANDY_HOME`), else `$SANDY_HOME/sandboxes/*` (default `~/.sandy`). Per sandy's host-side path contract each root has agent homes `claude/` (→ `~/.claude` in the sandbox) and `codex/` (→ `~/.codex`); everything else under a root is private. **Dot-entries** (`_is_sandbox_name`): skip `.rsync-from.*` (an in-progress `sandy --rsync-from` copy — its hook data was being summed twice) and `*.lock`; any other dot-dir is a REAL sandbox iff it holds `WORKSPACE.json` (a workspace like `~/.dotfiles` → sandbox `.dotfiles-<sha8>`). Sandy's own enumeration, hence `--print-state`, currently omits dot-named sandboxes, so they're always added by scanning `sandboxes/` directly. Tests are isolated from real sandboxes by `tests/conftest.py`.
|
|
37
|
+
2a. **Codex** (`codex_homes`, `discover_codex_files`, `parse_codex_lines`, `parse_codex_events`, `fetch_remote_codex`) - Reads `$CODEX_HOME` (default `~/.codex`) and every sandbox's `codex/` (sandy never sets CODEX_HOME), `sessions/` + `archived_sessions/` rollouts, incl. `.jsonl.zst` (stdlib `compression.zstd` on 3.14+, else the `zstd` CLI, else skipped and counted). Lines are `{timestamp, type, payload}`. Prefer `token_usage_record` items (per-response, dedup by `response_id` — forks replay parent history); fall back to legacy `event_msg`/`token_count` only before a file's first record, counting `last_token_usage` only when `total_token_usage.total_tokens` moved (it's re-emitted unchanged). **`input_tokens` includes `cached_input_tokens` and `cache_write_input_tokens`; `output_tokens` includes reasoning** — uncached input = input − cached − cache_write. Model/cwd from the latest `turn_context`; thread id + git branch from `session_meta`. Codex events carry `agent="codex"`.
|
|
38
|
+
3. **Remote aggregation** (`remote_sandbox_roots`, `fetch_remote_jsonl`, `parse_remote_events`, `fetch_remote_hook_data`, `parse_remote_hook_data`, `fetch_remote_codex`) - One SSH call per host finds its sandbox roots (same rules as locally, cached), then each fetch cats the host's own dirs plus the matching per-sandbox dirs with file separators and parses locally.
|
|
39
39
|
3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas`) - The hook gives **cost only**. Its `input_tokens`/`output_tokens`/`cache_*` fields are context-window SNAPSHOTS (`input_tokens` ≈ `cache_read + cache_write`; `output_tokens` is just the last message's), not cumulative — never use them for token counts. `cost_usd` is a session-lifetime cumulative that is unreliable in two ways handled by `compute_hook_deltas`: (1) it can already be large the first time a session is observed (a long-running/unattended session that only rendered its statusline partway through), so the **first observation is a baseline that is NOT counted** — booking a session's pre-existing cumulative onto its first-seen day is what produced a bogus $3,370 day; (2) it **drops** on resume/compaction (or when parallel processes share one session id), so a drop **rebaselines without counting the new level** — otherwise a day's booked spend can exceed the counter's own peak, which is impossible for real money. Net rule: **book only observed upward increments**; a rolled-up baseline seeds `prev` so already-counted days aren't recounted.
|
|
40
40
|
4. **JSONL parsing** (`parse_lines`, `parse_events`, `dedup_events`) - Extracts `assistant` events with token usage. Deduplicates by `requestId` across all of a host's files (last event wins) — resumed sessions (`--resume`/`--continue`) repeat events with the same `requestId` in new files. Events without `requestId` are never deduped.
|
|
41
41
|
5. **Aggregation** (`aggregate`) - Buckets events into daily and monthly summaries with token counts and costs.
|
|
42
|
-
6. **Output** (`print_report`) - Prints current month-to-date summary with projections, daily breakdown, and previous month summaries. Cost is composed **per source** (one source per machine): hook cost where a source has statusline coverage, accuracy-factor-adjusted JSONL cost otherwise, summed per date. The accuracy factor is measured per source over dates strictly after that source's hook install day.
|
|
42
|
+
6. **Output** (`print_report`) - Prints current month-to-date summary with projections, daily breakdown, and previous month summaries. Cost is composed **per source** (one source per machine): hook cost where a source has statusline coverage, accuracy-factor-adjusted JSONL cost otherwise, summed per date. The accuracy factor is measured per source over dates strictly after that source's hook install day; when unmeasurable, `DEFAULT_ACCURACY_FACTOR` is **1.0** — the old 1.25 "logs undercount ~20-25%" was entirely rate-card error and inflated unmeasured days 25%.
|
|
43
43
|
6a. **Explore** (`print_explore`, `--explore DATE`) - Drills into one day: dollar decomposition by token class, then by model / project / session / machine. Each JSONL event carries its `project` (from `cwd`), `session_id`, `source` (host) and `git_branch` for attribution. Per-session JSONL estimate is shown against the hook's authoritative per-session cost; a >15% day-total divergence is flagged so an inflated estimate (incomplete logs, unknown model) is distinguishable from real spend.
|
|
44
44
|
|
|
45
45
|
Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cctrack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
|
|
5
5
|
Project-URL: Homepage, https://github.com/rappdw/cctrack
|
|
6
6
|
Project-URL: Repository, https://github.com/rappdw/cctrack
|
|
@@ -50,7 +50,7 @@ cctrack --days 7
|
|
|
50
50
|
cctrack --remote dgx macbook-air
|
|
51
51
|
|
|
52
52
|
# Custom log directories
|
|
53
|
-
cctrack --dirs ~/.claude/projects
|
|
53
|
+
cctrack --dirs ~/.claude/projects ~/old-machine/projects
|
|
54
54
|
|
|
55
55
|
# Only one coding agent (default: all found)
|
|
56
56
|
cctrack --agent claude
|
|
@@ -82,7 +82,7 @@ Daily breakdown:
|
|
|
82
82
|
|
|
83
83
|
## What it does
|
|
84
84
|
|
|
85
|
-
1. Walks `~/.claude/projects/` and
|
|
85
|
+
1. Walks `~/.claude/projects/` — and each [Sandy](#sandy-sandboxes) sandbox's `claude/projects/` — for Claude Code transcripts, and each Codex home for Codex rollouts
|
|
86
86
|
2. Parses `assistant` events with token usage
|
|
87
87
|
3. Deduplicates by `requestId` (last event wins, matching Claude Code's semantics)
|
|
88
88
|
4. Calculates cost using Anthropic's published rates per model
|
|
@@ -90,7 +90,7 @@ Daily breakdown:
|
|
|
90
90
|
|
|
91
91
|
## Codex CLI
|
|
92
92
|
|
|
93
|
-
cctrack also reads [Codex CLI](https://github.com/openai/codex) usage from `$CODEX_HOME` (default `~/.codex`) — `sessions/` and `archived_sessions/` — and with `--remote`, from each host. When both agents have spend, the month-to-date block and `--explore` show a per-agent split.
|
|
93
|
+
cctrack also reads [Codex CLI](https://github.com/openai/codex) usage from `$CODEX_HOME` (default `~/.codex`) and from every [Sandy sandbox](#sandy-sandboxes)'s `codex/` — `sessions/` and `archived_sessions/` — and with `--remote`, from each host. When both agents have spend, the month-to-date block and `--explore` show a per-agent split.
|
|
94
94
|
|
|
95
95
|
How it's counted (verified against the Codex source, not inferred):
|
|
96
96
|
|
|
@@ -101,6 +101,18 @@ How it's counted (verified against the Codex source, not inferred):
|
|
|
101
101
|
|
|
102
102
|
Costs are at OpenAI's **API list prices**. If you use Codex through a ChatGPT plan, that's an API-equivalent value, not what you're billed — the same is true of Claude Code on a Claude subscription.
|
|
103
103
|
|
|
104
|
+
## Sandy sandboxes
|
|
105
|
+
|
|
106
|
+
cctrack finds every [Sandy](https://github.com/rappdw/sandy) sandbox on the machine (and on `--remote` hosts) and includes its Claude Code and Codex usage. It follows sandy's published host-side path contract:
|
|
107
|
+
|
|
108
|
+
- **Finding sandboxes:** `sandy --print-state` (`sandboxes[].path`) when sandy is installed, which follows `$SANDY_HOME`; otherwise `$SANDY_HOME/sandboxes/*` (default `~/.sandy/sandboxes`).
|
|
109
|
+
- **Per sandbox:** Claude Code transcripts from `claude/projects/`, statusline-hook data from `claude/cctrack/`, Codex rollouts from `codex/sessions/` and `codex/archived_sessions/`. Other files under `claude/` (`history.jsonl`, job timelines, tool-audit logs) aren't transcripts and are ignored.
|
|
110
|
+
- **Never double counted:** the hidden `.rsync-from.<name>.<pid>/` copy that exists while `sandy --rsync-from` is pulling a sandbox in is skipped, as are `.lock` directories. A sandbox's Codex home is never shared with the host's `~/.codex`, and anything copied between hosts with `sandy --rsync` is deduplicated by request/response id.
|
|
111
|
+
|
|
112
|
+
- **Dot-named sandboxes count:** a workspace named like a dotfile directory (e.g. `~/.dotfiles`) gets a sandbox whose name starts with a dot. Sandy's own listing currently omits these, so cctrack scans for them itself — any dot-directory holding `WORKSPACE.json` is a real sandbox.
|
|
113
|
+
|
|
114
|
+
Orphaned sandboxes (workspace deleted, sandbox not yet removed) still count: their usage is real history.
|
|
115
|
+
|
|
104
116
|
## Explore a day
|
|
105
117
|
|
|
106
118
|
When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
|
|
@@ -25,7 +25,7 @@ cctrack --days 7
|
|
|
25
25
|
cctrack --remote dgx macbook-air
|
|
26
26
|
|
|
27
27
|
# Custom log directories
|
|
28
|
-
cctrack --dirs ~/.claude/projects
|
|
28
|
+
cctrack --dirs ~/.claude/projects ~/old-machine/projects
|
|
29
29
|
|
|
30
30
|
# Only one coding agent (default: all found)
|
|
31
31
|
cctrack --agent claude
|
|
@@ -57,7 +57,7 @@ Daily breakdown:
|
|
|
57
57
|
|
|
58
58
|
## What it does
|
|
59
59
|
|
|
60
|
-
1. Walks `~/.claude/projects/` and
|
|
60
|
+
1. Walks `~/.claude/projects/` — and each [Sandy](#sandy-sandboxes) sandbox's `claude/projects/` — for Claude Code transcripts, and each Codex home for Codex rollouts
|
|
61
61
|
2. Parses `assistant` events with token usage
|
|
62
62
|
3. Deduplicates by `requestId` (last event wins, matching Claude Code's semantics)
|
|
63
63
|
4. Calculates cost using Anthropic's published rates per model
|
|
@@ -65,7 +65,7 @@ Daily breakdown:
|
|
|
65
65
|
|
|
66
66
|
## Codex CLI
|
|
67
67
|
|
|
68
|
-
cctrack also reads [Codex CLI](https://github.com/openai/codex) usage from `$CODEX_HOME` (default `~/.codex`) — `sessions/` and `archived_sessions/` — and with `--remote`, from each host. When both agents have spend, the month-to-date block and `--explore` show a per-agent split.
|
|
68
|
+
cctrack also reads [Codex CLI](https://github.com/openai/codex) usage from `$CODEX_HOME` (default `~/.codex`) and from every [Sandy sandbox](#sandy-sandboxes)'s `codex/` — `sessions/` and `archived_sessions/` — and with `--remote`, from each host. When both agents have spend, the month-to-date block and `--explore` show a per-agent split.
|
|
69
69
|
|
|
70
70
|
How it's counted (verified against the Codex source, not inferred):
|
|
71
71
|
|
|
@@ -76,6 +76,18 @@ How it's counted (verified against the Codex source, not inferred):
|
|
|
76
76
|
|
|
77
77
|
Costs are at OpenAI's **API list prices**. If you use Codex through a ChatGPT plan, that's an API-equivalent value, not what you're billed — the same is true of Claude Code on a Claude subscription.
|
|
78
78
|
|
|
79
|
+
## Sandy sandboxes
|
|
80
|
+
|
|
81
|
+
cctrack finds every [Sandy](https://github.com/rappdw/sandy) sandbox on the machine (and on `--remote` hosts) and includes its Claude Code and Codex usage. It follows sandy's published host-side path contract:
|
|
82
|
+
|
|
83
|
+
- **Finding sandboxes:** `sandy --print-state` (`sandboxes[].path`) when sandy is installed, which follows `$SANDY_HOME`; otherwise `$SANDY_HOME/sandboxes/*` (default `~/.sandy/sandboxes`).
|
|
84
|
+
- **Per sandbox:** Claude Code transcripts from `claude/projects/`, statusline-hook data from `claude/cctrack/`, Codex rollouts from `codex/sessions/` and `codex/archived_sessions/`. Other files under `claude/` (`history.jsonl`, job timelines, tool-audit logs) aren't transcripts and are ignored.
|
|
85
|
+
- **Never double counted:** the hidden `.rsync-from.<name>.<pid>/` copy that exists while `sandy --rsync-from` is pulling a sandbox in is skipped, as are `.lock` directories. A sandbox's Codex home is never shared with the host's `~/.codex`, and anything copied between hosts with `sandy --rsync` is deduplicated by request/response id.
|
|
86
|
+
|
|
87
|
+
- **Dot-named sandboxes count:** a workspace named like a dotfile directory (e.g. `~/.dotfiles`) gets a sandbox whose name starts with a dot. Sandy's own listing currently omits these, so cctrack scans for them itself — any dot-directory holding `WORKSPACE.json` is a real sandbox.
|
|
88
|
+
|
|
89
|
+
Orphaned sandboxes (workspace deleted, sandbox not yet removed) still count: their usage is real history.
|
|
90
|
+
|
|
79
91
|
## Explore a day
|
|
80
92
|
|
|
81
93
|
When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
|
|
@@ -281,12 +281,83 @@ def format_energy(wh: float) -> str:
|
|
|
281
281
|
|
|
282
282
|
# ── File discovery ──────────────────────────────────────────────────────
|
|
283
283
|
|
|
284
|
+
# Sandy sandboxes (https://github.com/rappdw/sandy). Per sandy's host-side path
|
|
285
|
+
# contract, each sandbox root holds one directory per agent home — claude/
|
|
286
|
+
# (mounted at ~/.claude inside the sandbox) and codex/ (~/.codex). Everything
|
|
287
|
+
# else under a sandbox root is private. Roots are found via `sandy
|
|
288
|
+
# --print-state` (sandboxes[].path, which follows $SANDY_HOME); without sandy on
|
|
289
|
+
# PATH we fall back to $SANDY_HOME/sandboxes/* (default ~/.sandy). Dot-entries
|
|
290
|
+
# are never sandboxes: an in-progress `sandy --rsync-from` pull lives in a
|
|
291
|
+
# hidden .rsync-from.<name>.<pid>/ copy, and counting it would double the
|
|
292
|
+
# sandbox's spend.
|
|
293
|
+
|
|
294
|
+
def sandy_home() -> Path:
|
|
295
|
+
return Path(os.environ.get("SANDY_HOME") or (Path.home() / ".sandy"))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _sandy_print_state() -> list[str] | None:
|
|
299
|
+
"""sandboxes[].path from `sandy --print-state`, or None if unavailable."""
|
|
300
|
+
import shutil
|
|
301
|
+
if not shutil.which("sandy"):
|
|
302
|
+
return None
|
|
303
|
+
try:
|
|
304
|
+
r = subprocess.run(["sandy", "--print-state"], capture_output=True,
|
|
305
|
+
text=True, timeout=30)
|
|
306
|
+
if r.returncode != 0:
|
|
307
|
+
return None
|
|
308
|
+
state = json.loads(r.stdout)
|
|
309
|
+
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, ValueError):
|
|
310
|
+
return None
|
|
311
|
+
boxes = state.get("sandboxes") if isinstance(state, dict) else None
|
|
312
|
+
if not isinstance(boxes, list):
|
|
313
|
+
return None
|
|
314
|
+
return [b["path"] for b in boxes if isinstance(b, dict) and b.get("path")]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_sandbox_name(name: str, has_workspace_json: bool | None) -> bool:
|
|
318
|
+
"""Which entries of sandboxes/ are real sandboxes. A sandbox is named
|
|
319
|
+
<workspace basename>-<sha8> and nothing strips a leading dot, so a workspace
|
|
320
|
+
like ~/.dotfiles gets a REAL sandbox ".dotfiles-1a2b3c4d". Among dot-entries:
|
|
321
|
+
.rsync-from.* is an in-progress copy of another sandbox (counting it double
|
|
322
|
+
counts); *.lock dirs hold only a pid; any other dot-dir is a sandbox iff it
|
|
323
|
+
holds WORKSPACE.json (written at every launch since sandy 0.10.1).
|
|
324
|
+
`has_workspace_json` None means "unknown" (trust the source)."""
|
|
325
|
+
if name in (".", ".."):
|
|
326
|
+
return False
|
|
327
|
+
if not name.startswith("."):
|
|
328
|
+
return True
|
|
329
|
+
if name.startswith(".rsync-from.") or name.endswith(".lock"):
|
|
330
|
+
return False
|
|
331
|
+
return has_workspace_json is not False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def sandbox_roots() -> list[Path]:
|
|
335
|
+
"""Root directory of every Sandy sandbox on this machine.
|
|
336
|
+
|
|
337
|
+
Uses `sandy --print-state` when available, else every entry of
|
|
338
|
+
$SANDY_HOME/sandboxes/. Dot-named sandboxes are always added by scanning
|
|
339
|
+
sandboxes/ directly: sandy's own enumeration (and so --print-state) skips
|
|
340
|
+
them today, which would silently drop their usage."""
|
|
341
|
+
base = sandy_home() / "sandboxes"
|
|
342
|
+
entries = sorted(p for p in base.iterdir() if p.is_dir()) if base.is_dir() else []
|
|
343
|
+
paths = _sandy_print_state()
|
|
344
|
+
candidates = [Path(p) for p in paths] if paths is not None else list(entries)
|
|
345
|
+
candidates += [p for p in entries if p.name.startswith(".")]
|
|
346
|
+
roots = {p for p in candidates
|
|
347
|
+
if _is_sandbox_name(p.name, (p / "WORKSPACE.json").is_file()
|
|
348
|
+
if p.name.startswith(".") else None)}
|
|
349
|
+
return sorted(roots)
|
|
350
|
+
|
|
351
|
+
|
|
284
352
|
def default_dirs() -> list[str]:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
353
|
+
"""Claude Code transcript dirs: the host's ~/.claude/projects plus each
|
|
354
|
+
sandbox's claude/projects. Only projects/ holds transcripts — claude/ also
|
|
355
|
+
has history.jsonl, job timelines and tool-audit logs, which aren't."""
|
|
356
|
+
dirs = [str(Path.home() / ".claude" / "projects")]
|
|
357
|
+
for root in sandbox_roots():
|
|
358
|
+
proj = root / "claude" / "projects"
|
|
359
|
+
if proj.is_dir():
|
|
360
|
+
dirs.append(str(proj))
|
|
290
361
|
return dirs
|
|
291
362
|
|
|
292
363
|
|
|
@@ -304,14 +375,75 @@ def discover_files(dirs: list[str]) -> list[str]:
|
|
|
304
375
|
|
|
305
376
|
# ── Remote host support ────────────────────────────────────────────────
|
|
306
377
|
|
|
307
|
-
|
|
378
|
+
_STATE_END = "___CCTRACK_SANDY_STATE_END___"
|
|
379
|
+
_remote_roots_cache: dict[str, list[str]] = {}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def remote_sandbox_roots(host: str) -> list[str]:
|
|
383
|
+
"""Sandy sandbox roots on a remote host, found the same way as locally:
|
|
384
|
+
`sandy --print-state` if the host has sandy, else the shell glob of
|
|
385
|
+
$SANDY_HOME/sandboxes/*/, plus dot-named sandboxes (dot-dirs holding
|
|
386
|
+
WORKSPACE.json), filtered by _is_sandbox_name. One SSH call, cached."""
|
|
387
|
+
if host in _remote_roots_cache:
|
|
388
|
+
return _remote_roots_cache[host]
|
|
389
|
+
remote_cmd = (
|
|
390
|
+
"command -v sandy >/dev/null 2>&1 && sandy --print-state 2>/dev/null; "
|
|
391
|
+
f"echo '{_STATE_END}'; "
|
|
392
|
+
'B="${SANDY_HOME:-$HOME/.sandy}/sandboxes"; '
|
|
393
|
+
'ls -1d "$B"/*/ 2>/dev/null; '
|
|
394
|
+
f"echo '{_STATE_END}'; "
|
|
395
|
+
'for d in "$B"/.*/; do [ -f "$d/WORKSPACE.json" ] && echo "$d"; done 2>/dev/null; true'
|
|
396
|
+
)
|
|
397
|
+
cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
|
|
398
|
+
try:
|
|
399
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=60).stdout or ""
|
|
400
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
401
|
+
out = ""
|
|
402
|
+
if not isinstance(out, str):
|
|
403
|
+
out = ""
|
|
404
|
+
state_part, _, rest = out.partition(_STATE_END)
|
|
405
|
+
glob_part, _, dot_part = rest.partition(_STATE_END)
|
|
406
|
+
paths = None
|
|
407
|
+
try:
|
|
408
|
+
state = json.loads(state_part) if state_part.strip() else None
|
|
409
|
+
boxes = state.get("sandboxes") if isinstance(state, dict) else None
|
|
410
|
+
if isinstance(boxes, list):
|
|
411
|
+
paths = [b["path"] for b in boxes if isinstance(b, dict) and b.get("path")]
|
|
412
|
+
except (json.JSONDecodeError, ValueError):
|
|
413
|
+
paths = None
|
|
414
|
+
if paths is None:
|
|
415
|
+
paths = [l.strip() for l in glob_part.splitlines() if l.strip()]
|
|
416
|
+
# Dot-dirs listed here were confirmed to hold WORKSPACE.json remotely.
|
|
417
|
+
dot_paths = [l.strip() for l in dot_part.splitlines() if l.strip()]
|
|
418
|
+
roots = set()
|
|
419
|
+
for p in paths:
|
|
420
|
+
p = p.rstrip("/")
|
|
421
|
+
name = p.rsplit("/", 1)[-1]
|
|
422
|
+
if _is_sandbox_name(name, None if not name.startswith(".") else
|
|
423
|
+
any(d.rstrip("/") == p for d in dot_paths)):
|
|
424
|
+
roots.add(p)
|
|
425
|
+
for d in dot_paths:
|
|
426
|
+
d = d.rstrip("/")
|
|
427
|
+
if _is_sandbox_name(d.rsplit("/", 1)[-1], True):
|
|
428
|
+
roots.add(d)
|
|
429
|
+
roots = sorted(roots)
|
|
430
|
+
_remote_roots_cache[host] = roots
|
|
431
|
+
return roots
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _q(path: str) -> str:
|
|
435
|
+
import shlex
|
|
436
|
+
return shlex.quote(path)
|
|
308
437
|
|
|
309
438
|
|
|
310
439
|
def fetch_remote_jsonl(host: str) -> str:
|
|
311
|
-
"""SSH to host and cat
|
|
440
|
+
"""SSH to host and cat Claude transcripts: ~/.claude/projects plus each
|
|
441
|
+
sandbox's claude/projects. Returns raw output."""
|
|
442
|
+
dirs = " ".join(['"$HOME/.claude/projects"']
|
|
443
|
+
+ [_q(f"{r}/claude/projects") for r in remote_sandbox_roots(host)])
|
|
312
444
|
cmd = [
|
|
313
445
|
"ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host,
|
|
314
|
-
f"find {
|
|
446
|
+
f"find {dirs} -name '*.jsonl' -exec echo '___CCTRACK_FILE_SEP___' \\; -exec cat {{}} \\; 2>/dev/null"
|
|
315
447
|
]
|
|
316
448
|
try:
|
|
317
449
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
@@ -350,18 +482,20 @@ BASELINE_FILE_SEP = "___CCTRACK_BASELINE_FILE___"
|
|
|
350
482
|
|
|
351
483
|
# Hook data locations on remote hosts: the host's own cctrack dir plus any
|
|
352
484
|
# Sandy sandbox equivalents (mirrors discover_hook_data_dirs).
|
|
353
|
-
REMOTE_HOOK_DIRS = "~/.claude/cctrack ~/.sandy/sandboxes"
|
|
354
485
|
|
|
355
486
|
|
|
356
487
|
def fetch_remote_hook_data(host: str) -> str:
|
|
357
|
-
"""SSH to host and cat all statusline JSONL + monthly summary files
|
|
488
|
+
"""SSH to host and cat all statusline JSONL + monthly summary files from
|
|
489
|
+
~/.claude/cctrack and each sandbox's claude/cctrack.
|
|
358
490
|
Returns raw output with file-type separators."""
|
|
491
|
+
hook_dirs = " ".join(['"$HOME/.claude/cctrack"']
|
|
492
|
+
+ [_q(f"{r}/claude/cctrack") for r in remote_sandbox_roots(host)])
|
|
359
493
|
remote_cmd = (
|
|
360
|
-
f"find {
|
|
494
|
+
f"find {hook_dirs} -name 'statusline*.jsonl' "
|
|
361
495
|
f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
362
|
-
f"find {
|
|
496
|
+
f"find {hook_dirs} -name 'monthly-*.json' "
|
|
363
497
|
f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
364
|
-
f"find {
|
|
498
|
+
f"find {hook_dirs} -name 'session-baselines.json' "
|
|
365
499
|
f"-exec echo '{BASELINE_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
366
500
|
f"true"
|
|
367
501
|
)
|
|
@@ -510,17 +644,15 @@ def cctrack_dir() -> Path:
|
|
|
510
644
|
|
|
511
645
|
|
|
512
646
|
def discover_hook_data_dirs() -> list[Path]:
|
|
513
|
-
"""Return all directories that may contain cctrack hook data
|
|
514
|
-
|
|
515
|
-
at ~/.sandy/sandboxes/*/claude/cctrack."""
|
|
647
|
+
"""Return all directories that may contain cctrack hook data: the host's
|
|
648
|
+
~/.claude/cctrack and each Sandy sandbox's claude/cctrack."""
|
|
516
649
|
dirs = []
|
|
517
650
|
if CCTRACK_DIR.exists():
|
|
518
651
|
dirs.append(CCTRACK_DIR)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
dirs.append(candidate)
|
|
652
|
+
for root in sandbox_roots():
|
|
653
|
+
candidate = root / "claude" / "cctrack"
|
|
654
|
+
if candidate.is_dir() and candidate not in dirs:
|
|
655
|
+
dirs.append(candidate)
|
|
524
656
|
return dirs
|
|
525
657
|
|
|
526
658
|
|
|
@@ -710,7 +842,12 @@ if cctrack_dir.exists():
|
|
|
710
842
|
print("ssh command not found", file=sys.stderr)
|
|
711
843
|
|
|
712
844
|
|
|
713
|
-
|
|
845
|
+
# Ratio of hook (Claude Code's own) cost to JSONL-priced cost, used only when
|
|
846
|
+
# it can't be measured from overlapping days. It was once 1.25 on the belief
|
|
847
|
+
# that logs undercount ~20-25%; that gap was entirely rate-card error, and with
|
|
848
|
+
# correct prices measured ratios sit at 0.99-1.02x. A stale 1.25 inflated every
|
|
849
|
+
# unmeasured day by a quarter.
|
|
850
|
+
DEFAULT_ACCURACY_FACTOR = 1.0
|
|
714
851
|
|
|
715
852
|
|
|
716
853
|
def prompt_hook_install() -> None:
|
|
@@ -867,14 +1004,36 @@ def codex_home() -> Path:
|
|
|
867
1004
|
return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
868
1005
|
|
|
869
1006
|
|
|
870
|
-
def
|
|
871
|
-
"""
|
|
1007
|
+
def codex_homes() -> list[Path]:
|
|
1008
|
+
"""The host's Codex home plus each Sandy sandbox's codex/ (sandy mounts it
|
|
1009
|
+
at ~/.codex inside the sandbox and never sets CODEX_HOME). A sandbox only
|
|
1010
|
+
has codex/ once Codex has run in it. Deduplicated by real path, in case
|
|
1011
|
+
CODEX_HOME points into a sandbox."""
|
|
1012
|
+
homes, seen = [], set()
|
|
1013
|
+
for h in [codex_home()] + [r / "codex" for r in sandbox_roots()]:
|
|
1014
|
+
if not h.is_dir():
|
|
1015
|
+
continue
|
|
1016
|
+
key = h.resolve()
|
|
1017
|
+
if key not in seen:
|
|
1018
|
+
seen.add(key)
|
|
1019
|
+
homes.append(h)
|
|
1020
|
+
return homes
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def discover_codex_files(home: Path | list[Path] | None = None) -> list[str]:
|
|
1024
|
+
"""Rollout files (plain and .zst) under sessions/ and archived_sessions/ of
|
|
1025
|
+
one Codex home, a list of them, or (default) every home on this machine.
|
|
872
1026
|
When both a plain file and its .zst sibling exist, the plain one wins
|
|
873
1027
|
(Codex materialises a .zst back to plain when it appends)."""
|
|
874
|
-
|
|
1028
|
+
if home is None:
|
|
1029
|
+
homes = codex_homes()
|
|
1030
|
+
elif isinstance(home, (list, tuple)):
|
|
1031
|
+
homes = list(home)
|
|
1032
|
+
else:
|
|
1033
|
+
homes = [home]
|
|
875
1034
|
found: dict[str, str] = {}
|
|
876
|
-
for sub in ("sessions", "archived_sessions"):
|
|
877
|
-
root =
|
|
1035
|
+
for sub, h in ((sub, h) for h in homes for sub in ("sessions", "archived_sessions")):
|
|
1036
|
+
root = h / sub
|
|
878
1037
|
if not root.exists():
|
|
879
1038
|
continue
|
|
880
1039
|
for f in root.rglob("rollout-*.jsonl*"):
|
|
@@ -1031,14 +1190,15 @@ REMOTE_CODEX_SEP = "___CCTRACK_CODEX_FILE___"
|
|
|
1031
1190
|
|
|
1032
1191
|
|
|
1033
1192
|
def fetch_remote_codex(host: str) -> str:
|
|
1034
|
-
"""SSH to host and stream its Codex rollouts
|
|
1035
|
-
when the host has `zstd
|
|
1193
|
+
"""SSH to host and stream its Codex rollouts — its own Codex home plus each
|
|
1194
|
+
sandbox's codex/ — decompressing .zst remotely when the host has `zstd`."""
|
|
1195
|
+
homes = ['"${CODEX_HOME:-$HOME/.codex}"'] + [_q(f"{r}/codex") for r in remote_sandbox_roots(host)]
|
|
1196
|
+
dirs = " ".join(f"{h}/sessions {h}/archived_sessions" for h in homes)
|
|
1036
1197
|
remote_cmd = (
|
|
1037
|
-
'
|
|
1038
|
-
f'find "$CH/sessions" "$CH/archived_sessions" -name "rollout-*.jsonl" '
|
|
1198
|
+
f'find {dirs} -name "rollout-*.jsonl" '
|
|
1039
1199
|
f"-exec echo '{REMOTE_CODEX_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
1040
1200
|
'if command -v zstd >/dev/null 2>&1; then '
|
|
1041
|
-
f'find
|
|
1201
|
+
f'find {dirs} -name "rollout-*.jsonl.zst" '
|
|
1042
1202
|
f"-exec echo '{REMOTE_CODEX_SEP}' \\; -exec zstd -dc {{}} \\; 2>/dev/null; fi; true"
|
|
1043
1203
|
)
|
|
1044
1204
|
cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
|
|
@@ -1636,8 +1796,16 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
1636
1796
|
return s
|
|
1637
1797
|
|
|
1638
1798
|
# Header
|
|
1639
|
-
|
|
1640
|
-
|
|
1799
|
+
agents_present = {src.get("agent", "claude") for src in sources
|
|
1800
|
+
if src["daily"] or src["hook_daily"]}
|
|
1801
|
+
if agents_present == {"codex"}:
|
|
1802
|
+
title = "cctrack — Codex Cost Report"
|
|
1803
|
+
elif "codex" in agents_present:
|
|
1804
|
+
title = "cctrack — Claude Code + Codex Cost Report"
|
|
1805
|
+
else:
|
|
1806
|
+
title = "cctrack — Claude Code Cost Report"
|
|
1807
|
+
print(title)
|
|
1808
|
+
print("═" * len(title))
|
|
1641
1809
|
print()
|
|
1642
1810
|
|
|
1643
1811
|
# Current month-to-date
|
|
@@ -1699,7 +1867,7 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
1699
1867
|
print(f" Sessions: {mtd_sessions:>15}")
|
|
1700
1868
|
|
|
1701
1869
|
if hooks_exist and est_days > 0:
|
|
1702
|
-
factor_source = "measured" if factor_is_measured else "
|
|
1870
|
+
factor_source = "measured" if factor_is_measured else "default"
|
|
1703
1871
|
cost_note = f" ({accuracy_factor:.2f}x adjustment, {factor_source})"
|
|
1704
1872
|
if auth_days > 0:
|
|
1705
1873
|
cost_note = f" ({auth_days}d authoritative, {est_days}d adjusted {accuracy_factor:.2f}x)"
|
|
@@ -1777,7 +1945,7 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
1777
1945
|
if factor_is_measured:
|
|
1778
1946
|
print(f" ~ = estimated (JSONL-derived, {accuracy_factor:.1f}x undercount measured from hook data)")
|
|
1779
1947
|
else:
|
|
1780
|
-
print(" ~ = estimated (JSONL-derived
|
|
1948
|
+
print(" ~ = estimated (JSONL-derived; no hook overlap yet to measure accuracy)")
|
|
1781
1949
|
|
|
1782
1950
|
# Previous months summary
|
|
1783
1951
|
prev_months = [m for m in sorted_months if m != current_month]
|
|
@@ -2128,7 +2296,7 @@ def main():
|
|
|
2128
2296
|
)
|
|
2129
2297
|
parser.add_argument(
|
|
2130
2298
|
"--dirs", nargs="+", default=None,
|
|
2131
|
-
help="Custom
|
|
2299
|
+
help="Custom Claude transcript directories to scan (default: ~/.claude/projects plus each Sandy sandbox's claude/projects)"
|
|
2132
2300
|
)
|
|
2133
2301
|
parser.add_argument(
|
|
2134
2302
|
"--remote", nargs="+", default=None, metavar="HOST",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared test isolation.
|
|
2
|
+
|
|
3
|
+
cctrack discovers Sandy sandboxes by running `sandy --print-state` and by
|
|
4
|
+
reading $SANDY_HOME. Tests must never touch a developer's real sandboxes, so by
|
|
5
|
+
default the sandy probe returns "unavailable" and SANDY_HOME points at an empty
|
|
6
|
+
temp dir. Tests that exercise the probe itself use the `real_sandy_probe` marker.
|
|
7
|
+
"""
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import cctrack
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pytest_configure(config):
|
|
14
|
+
config.addinivalue_line(
|
|
15
|
+
"markers", "real_sandy_probe: don't stub cctrack._sandy_print_state")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _isolate_sandy(request, monkeypatch, tmp_path):
|
|
20
|
+
monkeypatch.setenv("SANDY_HOME", str(tmp_path / "no-sandy"))
|
|
21
|
+
if not request.node.get_closest_marker("real_sandy_probe"):
|
|
22
|
+
monkeypatch.setattr(cctrack, "_sandy_print_state", lambda: None)
|
|
23
|
+
cctrack._remote_roots_cache.clear()
|
|
24
|
+
yield
|
|
25
|
+
cctrack._remote_roots_cache.clear()
|
|
@@ -1180,7 +1180,8 @@ def test_adjusted_cost_with_measured_factor(capsys):
|
|
|
1180
1180
|
|
|
1181
1181
|
|
|
1182
1182
|
def test_adjusted_cost_without_overlap_uses_default(capsys):
|
|
1183
|
-
"""Without overlap,
|
|
1183
|
+
"""Without overlap, the default factor applies. It is 1.0: with correct
|
|
1184
|
+
prices, logs and Claude Code's own cost agree (measured 0.99-1.02x)."""
|
|
1184
1185
|
fake_now = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
|
|
1185
1186
|
|
|
1186
1187
|
daily = {
|
|
@@ -1200,12 +1201,13 @@ def test_adjusted_cost_without_overlap_uses_default(capsys):
|
|
|
1200
1201
|
hook_install_date="2026-04-15")
|
|
1201
1202
|
|
|
1202
1203
|
out = capsys.readouterr().out
|
|
1203
|
-
# MTD:
|
|
1204
|
-
assert
|
|
1205
|
-
|
|
1204
|
+
# MTD: estimated(4 + 4) + hook(5) = 13.00 — no unmeasured inflation
|
|
1205
|
+
assert cctrack.DEFAULT_ACCURACY_FACTOR == 1.0
|
|
1206
|
+
assert "13.00" in out
|
|
1207
|
+
# Tilde on estimated days, and the legend says why
|
|
1206
1208
|
assert "~" in out
|
|
1207
|
-
|
|
1208
|
-
assert "20-25%" in out
|
|
1209
|
+
assert "no hook overlap" in out
|
|
1210
|
+
assert "20-25%" not in out
|
|
1209
1211
|
|
|
1210
1212
|
|
|
1211
1213
|
# ── Previous month accuracy estimation ─────────────────────────────────
|
|
@@ -1215,7 +1217,7 @@ def test_previous_month_estimated_actual(capsys):
|
|
|
1215
1217
|
fake_now = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
|
|
1216
1218
|
|
|
1217
1219
|
daily = {
|
|
1218
|
-
"2026-04-
|
|
1220
|
+
"2026-04-16": make_bucket(input=1000, cost=4.00),
|
|
1219
1221
|
"2026-03-20": make_bucket(input=2000, cost=8.00),
|
|
1220
1222
|
"2026-03-21": make_bucket(input=1500, cost=6.00),
|
|
1221
1223
|
}
|
|
@@ -1224,7 +1226,9 @@ def test_previous_month_estimated_actual(capsys):
|
|
|
1224
1226
|
"2026-03": make_bucket(input=3500, cost=14.00),
|
|
1225
1227
|
}
|
|
1226
1228
|
hook_daily = {
|
|
1227
|
-
|
|
1229
|
+
# post-install overlap → a MEASURED 1.25x factor (the install day
|
|
1230
|
+
# itself is excluded from measurement)
|
|
1231
|
+
"2026-04-16": {"cost": 5.00, "sessions": 1},
|
|
1228
1232
|
}
|
|
1229
1233
|
|
|
1230
1234
|
with patch("cctrack.datetime") as mock_dt:
|
|
@@ -1235,7 +1239,7 @@ def test_previous_month_estimated_actual(capsys):
|
|
|
1235
1239
|
hook_install_date="2026-04-15")
|
|
1236
1240
|
|
|
1237
1241
|
out = capsys.readouterr().out
|
|
1238
|
-
# Previous month cost is JSONL-only: (8+6)
|
|
1242
|
+
# Previous month cost is JSONL-only: (8+6) × measured 1.25 = 17.50
|
|
1239
1243
|
assert "Previous months:" in out
|
|
1240
1244
|
assert "17.50" in out
|
|
1241
1245
|
assert "~" in out
|
|
@@ -1468,8 +1472,10 @@ def test_discover_hook_data_dirs_host_only():
|
|
|
1468
1472
|
assert len(dirs) == 1
|
|
1469
1473
|
|
|
1470
1474
|
|
|
1471
|
-
def test_discover_hook_data_dirs_with_sandy():
|
|
1472
|
-
"""Finds both host and Sandy sandbox cctrack dirs.
|
|
1475
|
+
def test_discover_hook_data_dirs_with_sandy(monkeypatch):
|
|
1476
|
+
"""Finds both host and Sandy sandbox cctrack dirs.
|
|
1477
|
+
With SANDY_HOME unset, sandboxes default to ~/.sandy/sandboxes."""
|
|
1478
|
+
monkeypatch.delenv("SANDY_HOME", raising=False)
|
|
1473
1479
|
with tempfile.TemporaryDirectory() as home:
|
|
1474
1480
|
cctrack_dir = Path(home) / ".claude" / "cctrack"
|
|
1475
1481
|
cctrack_dir.mkdir(parents=True)
|
|
@@ -1487,8 +1493,10 @@ def test_discover_hook_data_dirs_with_sandy():
|
|
|
1487
1493
|
assert len(dirs) == 3
|
|
1488
1494
|
|
|
1489
1495
|
|
|
1490
|
-
def test_discover_hook_data_dirs_sandy_only():
|
|
1491
|
-
"""Finds Sandy sandbox dirs even when host dir doesn't exist.
|
|
1496
|
+
def test_discover_hook_data_dirs_sandy_only(monkeypatch):
|
|
1497
|
+
"""Finds Sandy sandbox dirs even when host dir doesn't exist.
|
|
1498
|
+
With SANDY_HOME unset, sandboxes default to ~/.sandy/sandboxes."""
|
|
1499
|
+
monkeypatch.delenv("SANDY_HOME", raising=False)
|
|
1492
1500
|
with tempfile.TemporaryDirectory() as home:
|
|
1493
1501
|
cctrack_dir = Path(home) / ".claude" / "cctrack"
|
|
1494
1502
|
# Don't create host dir
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Tests for Sandy sandbox discovery (Claude transcripts, hook data, Codex).
|
|
2
|
+
|
|
3
|
+
Layout per sandy's published host-side path contract: each sandbox root holds
|
|
4
|
+
one directory per agent home — claude/ (~/.claude inside the sandbox) and
|
|
5
|
+
codex/ (~/.codex). Roots come from `sandy --print-state` (sandboxes[].path,
|
|
6
|
+
which follows $SANDY_HOME) or, without sandy, $SANDY_HOME/sandboxes/*. A
|
|
7
|
+
dot-entry such as .rsync-from.<name>.<pid>/ is an in-progress `sandy
|
|
8
|
+
--rsync-from` copy and must never be counted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest.mock import patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
import cctrack
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Fixture builders ────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def _w(path: Path, rows: list[dict]) -> None:
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rollout(thread, rid, inp, model="gpt-5.4"):
|
|
31
|
+
"""A one-response Codex rollout. Requests stay under 272K tokens so they
|
|
32
|
+
bill at the standard gpt-5.4 rate ($2.50/M input)."""
|
|
33
|
+
return [
|
|
34
|
+
{"timestamp": "2026-10-12T09:00:00.000Z", "type": "session_meta",
|
|
35
|
+
"payload": {"id": thread, "cwd": f"/w/{thread}"}},
|
|
36
|
+
{"timestamp": "2026-10-12T09:00:01.000Z", "type": "turn_context",
|
|
37
|
+
"payload": {"model": model, "cwd": f"/w/{thread}"}},
|
|
38
|
+
{"timestamp": "2026-10-12T09:00:02.000Z", "type": "token_usage_record",
|
|
39
|
+
"payload": {"thread_id": thread, "response_id": rid,
|
|
40
|
+
"usage": {"input_tokens": inp, "cached_input_tokens": 0,
|
|
41
|
+
"cache_write_input_tokens": 0, "output_tokens": 0,
|
|
42
|
+
"reasoning_output_tokens": 0, "total_tokens": inp}}},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _claude(rid, sid):
|
|
47
|
+
return [{"type": "assistant", "requestId": rid, "sessionId": sid,
|
|
48
|
+
"timestamp": "2026-10-12T09:00:00.000Z", "cwd": "/w/x",
|
|
49
|
+
"message": {"model": "claude-opus-5",
|
|
50
|
+
"usage": {"input_tokens": 200_000, "output_tokens": 0}}}]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _hook(cost):
|
|
54
|
+
return [{"_ts": "2026-10-12T09:00:00+00:00", "session_id": "s1", "cost_usd": 0},
|
|
55
|
+
{"_ts": "2026-10-12T10:00:00+00:00", "session_id": "s1", "cost_usd": cost}]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def fake_host(tmp_path, monkeypatch):
|
|
60
|
+
"""A host with its own ~/.codex, two sandboxes (one named with a space),
|
|
61
|
+
an in-progress rsync copy, and non-transcript files under claude/."""
|
|
62
|
+
home = tmp_path / "home"
|
|
63
|
+
sb = home / ".sandy" / "sandboxes"
|
|
64
|
+
_w(home / ".codex/sessions/2026/10/12/rollout-a.jsonl", _rollout("host", "r-host", 100_000))
|
|
65
|
+
_w(sb / "web/codex/sessions/2026/10/12/rollout-b.jsonl", _rollout("web", "r-web", 200_000))
|
|
66
|
+
_w(sb / "my box/codex/archived_sessions/rollout-c.jsonl", _rollout("mybox", "r-mybox", 40_000))
|
|
67
|
+
_w(sb / "web/claude/projects/p/s.jsonl", _claude("req-web", "cw"))
|
|
68
|
+
_w(sb / "web/claude/cctrack/statusline-2026-10-12.jsonl", _hook(5.0))
|
|
69
|
+
# Not transcripts — shaped like them on purpose, so only path scoping can
|
|
70
|
+
# keep them out (real ones have other shapes).
|
|
71
|
+
_w(sb / "web/claude/history.jsonl", _claude("req-hist", "ch"))
|
|
72
|
+
_w(sb / "web/claude/jobs/j1/timeline.jsonl", _claude("req-job", "cj"))
|
|
73
|
+
# In-progress `sandy --rsync-from` pull: a hidden full copy. Distinct ids,
|
|
74
|
+
# so only dot-entry skipping (not dedup) keeps it out.
|
|
75
|
+
ghost = sb / ".rsync-from.web.4242"
|
|
76
|
+
_w(ghost / "codex/sessions/2026/10/12/rollout-z.jsonl", _rollout("ghost", "r-ghost", 250_000))
|
|
77
|
+
_w(ghost / "claude/projects/p/s.jsonl", _claude("req-ghost", "cg"))
|
|
78
|
+
_w(ghost / "claude/cctrack/statusline-2026-10-12.jsonl", _hook(5.0))
|
|
79
|
+
(sb / ".web.lock").mkdir()
|
|
80
|
+
# A copy also carries WORKSPACE.json, so only its NAME can exclude it.
|
|
81
|
+
(ghost / "WORKSPACE.json").write_text("{}")
|
|
82
|
+
# A REAL sandbox for a workspace named like a dotfile dir (~/.dotfiles).
|
|
83
|
+
# sandy's own enumeration (and --print-state) currently misses these.
|
|
84
|
+
dot = sb / ".dotfiles-1a2b3c4d"
|
|
85
|
+
(dot).mkdir()
|
|
86
|
+
(dot / "WORKSPACE.json").write_text("{}")
|
|
87
|
+
_w(dot / "codex/sessions/2026/10/12/rollout-d.jsonl", _rollout("dotfiles", "r-dot", 80_000))
|
|
88
|
+
lock = sb / "..dotfiles-1a2b3c4d.lock"
|
|
89
|
+
lock.mkdir()
|
|
90
|
+
(lock / "WORKSPACE.json").write_text("{}") # even so: a lock is never a sandbox
|
|
91
|
+
# A dot-dir that isn't a sandbox (no WORKSPACE.json) — must be ignored.
|
|
92
|
+
_w(sb / ".scratch/codex/sessions/2026/10/12/rollout-s.jsonl", _rollout("stray", "r-stray", 123_000))
|
|
93
|
+
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
|
94
|
+
monkeypatch.delenv("SANDY_HOME", raising=False)
|
|
95
|
+
monkeypatch.delenv("CODEX_HOME", raising=False)
|
|
96
|
+
monkeypatch.setattr(cctrack, "CCTRACK_DIR", home / ".claude" / "cctrack")
|
|
97
|
+
return home
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cost(events):
|
|
101
|
+
return round(sum(v["cost"] for v in cctrack.aggregate(events)[0].values()), 6)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# host 100K + web 200K + "my box" 40K + .dotfiles 80K tokens of gpt-5.4 input at $2.50/M
|
|
105
|
+
CODEX_EXPECTED = (100_000 + 200_000 + 40_000 + 80_000) * 2.50 / 1e6
|
|
106
|
+
CODEX_THREADS = ["dotfiles", "host", "mybox", "web"]
|
|
107
|
+
ROOT_NAMES = [".dotfiles-1a2b3c4d", "my box", "web"]
|
|
108
|
+
# 200K tokens of claude-opus-5 input at $5/M
|
|
109
|
+
CLAUDE_EXPECTED = 200_000 * 5.00 / 1e6
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Sandbox roots ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def test_roots_fallback_dot_entry_rules(fake_host):
|
|
115
|
+
"""Skips the rsync copy, lock dirs and dot-dirs without WORKSPACE.json;
|
|
116
|
+
keeps a real dot-named sandbox."""
|
|
117
|
+
assert [p.name for p in cctrack.sandbox_roots()] == ROOT_NAMES
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_dot_named_sandbox_found_even_with_print_state(fake_host, monkeypatch):
|
|
121
|
+
"""sandy --print-state currently omits dot-named sandboxes (a sandy bug);
|
|
122
|
+
cctrack adds them by scanning sandboxes/ itself."""
|
|
123
|
+
sb = fake_host / ".sandy" / "sandboxes"
|
|
124
|
+
monkeypatch.setattr(cctrack, "_sandy_print_state",
|
|
125
|
+
lambda: [str(sb / "web"), str(sb / "my box")])
|
|
126
|
+
assert [p.name for p in cctrack.sandbox_roots()] == ROOT_NAMES
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.parametrize("name,ws,expected", [
|
|
130
|
+
("web-1a2b3c4d", None, True),
|
|
131
|
+
(".dotfiles-1a2b3c4d", True, True),
|
|
132
|
+
(".dotfiles-1a2b3c4d", False, False), # dot-dir without WORKSPACE.json
|
|
133
|
+
(".rsync-from.web-1a2b3c4d.4242", True, False),
|
|
134
|
+
("..dotfiles-1a2b3c4d.lock", True, False),
|
|
135
|
+
(".web-1a2b3c4d.lock", False, False),
|
|
136
|
+
(".", True, False), ("..", True, False),
|
|
137
|
+
])
|
|
138
|
+
def test_is_sandbox_name(name, ws, expected):
|
|
139
|
+
assert cctrack._is_sandbox_name(name, ws) is expected
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_roots_follow_sandy_home(tmp_path, monkeypatch):
|
|
143
|
+
sh = tmp_path / "elsewhere"
|
|
144
|
+
(sh / "sandboxes" / "relocated").mkdir(parents=True)
|
|
145
|
+
monkeypatch.setenv("SANDY_HOME", str(sh))
|
|
146
|
+
assert [p.name for p in cctrack.sandbox_roots()] == ["relocated"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_roots_prefer_print_state(tmp_path, monkeypatch):
|
|
150
|
+
a, hidden = tmp_path / "a", tmp_path / ".rsync-from.a.1"
|
|
151
|
+
monkeypatch.setattr(cctrack, "_sandy_print_state", lambda: [str(a), str(hidden)])
|
|
152
|
+
assert cctrack.sandbox_roots() == [a]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_no_sandy_no_sandboxes(tmp_path):
|
|
156
|
+
# conftest points SANDY_HOME at an empty temp dir and stubs the probe
|
|
157
|
+
assert cctrack.sandbox_roots() == []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.real_sandy_probe
|
|
161
|
+
def test_print_state_probe_parses_paths():
|
|
162
|
+
out = json.dumps({"sandboxes": [{"name": "a", "path": "/s/a", "agents": ["codex"]},
|
|
163
|
+
{"name": "b", "path": "/s/b"}, {"name": "nopath"}]})
|
|
164
|
+
done = subprocess.CompletedProcess(["sandy"], 0, stdout=out, stderr="")
|
|
165
|
+
with patch("shutil.which", return_value="/usr/bin/sandy"), \
|
|
166
|
+
patch("subprocess.run", return_value=done) as run:
|
|
167
|
+
assert cctrack._sandy_print_state() == ["/s/a", "/s/b"]
|
|
168
|
+
assert run.call_args[0][0] == ["sandy", "--print-state"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.real_sandy_probe
|
|
172
|
+
@pytest.mark.parametrize("which,result", [
|
|
173
|
+
(None, None), # no sandy
|
|
174
|
+
("/usr/bin/sandy", subprocess.CompletedProcess([], 1, stdout="", stderr="x")), # failed
|
|
175
|
+
("/usr/bin/sandy", subprocess.CompletedProcess([], 0, stdout="not json", stderr="")),
|
|
176
|
+
("/usr/bin/sandy", subprocess.CompletedProcess([], 0, stdout='{"other": 1}', stderr="")),
|
|
177
|
+
])
|
|
178
|
+
def test_print_state_probe_unavailable(which, result):
|
|
179
|
+
with patch("shutil.which", return_value=which), \
|
|
180
|
+
patch("subprocess.run", return_value=result):
|
|
181
|
+
assert cctrack._sandy_print_state() is None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── Local discovery ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def test_codex_found_in_every_sandbox(fake_host):
|
|
187
|
+
events, skipped = cctrack.parse_codex_events(cctrack.discover_codex_files())
|
|
188
|
+
assert skipped == 0
|
|
189
|
+
assert sorted(e["session_id"] for e in events) == CODEX_THREADS
|
|
190
|
+
assert _cost(events) == pytest.approx(CODEX_EXPECTED)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_claude_scan_scoped_to_projects(fake_host):
|
|
194
|
+
"""history.jsonl / jobs timelines / codex rollouts aren't transcripts."""
|
|
195
|
+
dirs = cctrack.default_dirs()
|
|
196
|
+
assert all(d.endswith("projects") for d in dirs)
|
|
197
|
+
events = cctrack.parse_events(cctrack.discover_files(dirs))
|
|
198
|
+
assert [e["_request_id"] for e in events] == ["req-web"]
|
|
199
|
+
assert _cost(events) == pytest.approx(CLAUDE_EXPECTED)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_rsync_copy_hook_data_not_double_counted(fake_host):
|
|
203
|
+
"""Regression: hook data in a hidden .rsync-from copy was summed again
|
|
204
|
+
($10 reported for $5 of real spend)."""
|
|
205
|
+
dirs = cctrack.discover_hook_data_dirs()
|
|
206
|
+
assert not any(".rsync-from" in str(d) for d in dirs)
|
|
207
|
+
events = []
|
|
208
|
+
for d in dirs:
|
|
209
|
+
events += cctrack.compute_hook_deltas(cctrack.parse_hook_rows(d),
|
|
210
|
+
cctrack.load_session_baselines(d))
|
|
211
|
+
assert cctrack.aggregate_hook_data(events)["2026-10-12"]["cost"] == pytest.approx(5.0)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_codex_homes_dedup_by_real_path(fake_host, monkeypatch):
|
|
215
|
+
"""If CODEX_HOME points into a sandbox, that home isn't read twice."""
|
|
216
|
+
monkeypatch.setenv("CODEX_HOME", str(fake_host / ".sandy/sandboxes/web/codex"))
|
|
217
|
+
homes = [h.resolve() for h in cctrack.codex_homes()]
|
|
218
|
+
assert len(homes) == len(set(homes))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_sandbox_without_codex_is_fine(fake_host):
|
|
222
|
+
shutil.rmtree(fake_host / ".sandy/sandboxes/my box/codex")
|
|
223
|
+
events, _ = cctrack.parse_codex_events(cctrack.discover_codex_files())
|
|
224
|
+
assert sorted(e["session_id"] for e in events) == ["dotfiles", "host", "web"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Remote discovery ────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
def _remote_out(state: str, glob_lines: list[str],
|
|
230
|
+
dot_lines: list[str] = ()) -> subprocess.CompletedProcess:
|
|
231
|
+
out = (f"{state}\n{cctrack._STATE_END}\n" + "\n".join(glob_lines) + "\n"
|
|
232
|
+
f"{cctrack._STATE_END}\n" + "\n".join(dot_lines) + "\n")
|
|
233
|
+
return subprocess.CompletedProcess(["ssh"], 0, stdout=out, stderr="")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_remote_roots_prefer_print_state():
|
|
237
|
+
state = json.dumps({"sandboxes": [{"path": "/h/.sandy/sandboxes/a"},
|
|
238
|
+
{"path": "/h/.sandy/sandboxes/.rsync-from.a.9"}]})
|
|
239
|
+
with patch("subprocess.run", return_value=_remote_out(state, ["/h/.sandy/sandboxes/zzz/"])):
|
|
240
|
+
assert cctrack.remote_sandbox_roots("h") == ["/h/.sandy/sandboxes/a"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_remote_roots_glob_fallback_and_cache():
|
|
244
|
+
out = _remote_out("", ["/h/.sandy/sandboxes/a/", "/h/.sandy/sandboxes/b c/"])
|
|
245
|
+
with patch("subprocess.run", return_value=out) as run:
|
|
246
|
+
assert cctrack.remote_sandbox_roots("h") == ["/h/.sandy/sandboxes/a",
|
|
247
|
+
"/h/.sandy/sandboxes/b c"]
|
|
248
|
+
cctrack.remote_sandbox_roots("h")
|
|
249
|
+
assert run.call_count == 1 # one SSH round trip per host
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_remote_roots_add_dot_named_sandboxes():
|
|
253
|
+
"""Dot-dirs the remote confirmed hold WORKSPACE.json are added, except
|
|
254
|
+
rsync copies and locks."""
|
|
255
|
+
state = json.dumps({"sandboxes": [{"path": "/h/.sandy/sandboxes/a"}]})
|
|
256
|
+
dots = ["/h/.sandy/sandboxes/.dotfiles-1a2b3c4d/",
|
|
257
|
+
"/h/.sandy/sandboxes/.rsync-from.a.9/",
|
|
258
|
+
"/h/.sandy/sandboxes/..dotfiles-1a2b3c4d.lock/",
|
|
259
|
+
"/h/.sandy/sandboxes/./"]
|
|
260
|
+
with patch("subprocess.run", return_value=_remote_out(state, [], dots)):
|
|
261
|
+
assert cctrack.remote_sandbox_roots("h") == ["/h/.sandy/sandboxes/.dotfiles-1a2b3c4d",
|
|
262
|
+
"/h/.sandy/sandboxes/a"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_remote_roots_unreachable_host():
|
|
266
|
+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ssh", 60)):
|
|
267
|
+
assert cctrack.remote_sandbox_roots("h") == []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.mark.skipif(not shutil.which("bash") or not shutil.which("find"),
|
|
271
|
+
reason="needs bash and find")
|
|
272
|
+
def test_remote_commands_run_in_a_real_shell(fake_host):
|
|
273
|
+
"""End-to-end: the exact remote commands, executed by a real shell against
|
|
274
|
+
the fake host (ssh swapped for local bash), find every sandbox — including
|
|
275
|
+
one whose name has a space — and skip the rsync copy and non-transcripts."""
|
|
276
|
+
real_run = subprocess.run
|
|
277
|
+
|
|
278
|
+
def via_bash(cmd, **kw):
|
|
279
|
+
if cmd and cmd[0] == "ssh":
|
|
280
|
+
env = {**os.environ, "HOME": str(fake_host)}
|
|
281
|
+
env.pop("SANDY_HOME", None)
|
|
282
|
+
env.pop("CODEX_HOME", None)
|
|
283
|
+
# no `sandy` on this PATH, so the glob fallback is exercised
|
|
284
|
+
env["PATH"] = "/usr/bin:/bin"
|
|
285
|
+
return real_run(["bash", "-c", cmd[-1]], **{**kw, "env": env})
|
|
286
|
+
return real_run(cmd, **kw)
|
|
287
|
+
|
|
288
|
+
with patch("subprocess.run", side_effect=via_bash), patch("sys.stderr"):
|
|
289
|
+
roots = cctrack.remote_sandbox_roots("fake")
|
|
290
|
+
codex = cctrack.parse_remote_codex_events("fake")
|
|
291
|
+
claude = cctrack.parse_remote_events("fake")
|
|
292
|
+
rows, _, _ = cctrack.parse_remote_hook_data("fake")
|
|
293
|
+
assert [r.rsplit("/", 1)[-1] for r in roots] == ROOT_NAMES
|
|
294
|
+
assert sorted(e["session_id"] for e in codex) == CODEX_THREADS
|
|
295
|
+
assert _cost(codex) == pytest.approx(CODEX_EXPECTED)
|
|
296
|
+
assert [e["_request_id"] for e in claude] == ["req-web"]
|
|
297
|
+
hook = cctrack.aggregate_hook_data(cctrack.compute_hook_deltas(rows))
|
|
298
|
+
assert hook["2026-10-12"]["cost"] == pytest.approx(5.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|