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.
@@ -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`) - Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for `.jsonl` 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`) - SSHs to hosts, cats all JSONL logs plus statusline hook data (statusline files, monthly summaries, session baselines) with file separators, parses locally.
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.8.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 ~/.sandy/sandboxes
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 `~/.sandy/sandboxes/` for JSONL log files
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 ~/.sandy/sandboxes
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 `~/.sandy/sandboxes/` for JSONL log files
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- home = Path.home()
286
- dirs = [str(home / ".claude" / "projects")]
287
- sandy = home / ".sandy" / "sandboxes"
288
- if sandy.exists():
289
- dirs.append(str(sandy))
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
- REMOTE_DIRS = "~/.claude/projects ~/.sandy/sandboxes"
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 all JSONL files from default dirs. Returns raw output."""
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 {REMOTE_DIRS} -name '*.jsonl' -exec echo '___CCTRACK_FILE_SEP___' \\; -exec cat {{}} \\; 2>/dev/null"
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 {REMOTE_HOOK_DIRS} -name 'statusline*.jsonl' "
494
+ f"find {hook_dirs} -name 'statusline*.jsonl' "
361
495
  f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
362
- f"find {REMOTE_HOOK_DIRS} -name 'monthly-*.json' "
496
+ f"find {hook_dirs} -name 'monthly-*.json' "
363
497
  f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
364
- f"find {REMOTE_HOOK_DIRS} -name 'session-baselines.json' "
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
- Includes the host's ~/.claude/cctrack and any Sandy sandbox equivalents
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
- sandy = Path.home() / ".sandy" / "sandboxes"
520
- if sandy.exists():
521
- for candidate in sandy.glob("*/claude/cctrack"):
522
- if candidate.is_dir() and candidate not in dirs:
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
- DEFAULT_ACCURACY_FACTOR = 1.25
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 discover_codex_files(home: Path | None = None) -> list[str]:
871
- """Rollout files (plain and .zst) under sessions/ and archived_sessions/.
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
- home = home or codex_home()
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 = home / sub
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 (decompressing .zst remotely
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
- 'CH="${CODEX_HOME:-$HOME/.codex}"; '
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 "$CH/sessions" "$CH/archived_sessions" -name "rollout-*.jsonl.zst" '
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
- print("cctrack — Claude Code Cost Report")
1640
- print("══════════════════════════════════")
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 "estimated ~20-25%"
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, may undercount by ~20-25%)")
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 log directories to scan (default: ~/.claude/projects and ~/.sandy/sandboxes)"
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, uses default 1.25x factor for adjustment."""
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: adjusted(4*1.25 + 4*1.25) + hook(5) = 10 + 5 = 15.00
1204
- assert "15.00" in out
1205
- # Tilde on estimated days
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
- # Legend mentions 20-25%
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-15": make_bucket(input=1000, cost=4.00),
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
- "2026-04-15": {"cost": 5.00, "sessions": 1}, # 1.25x factor
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)*1.25 = 17.50 (adjusted per day)
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