cctally 1.13.0 → 1.15.0
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.
- package/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/bin/_cctally_core.py +50 -2
- package/bin/_cctally_db.py +91 -0
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_setup.py +80 -0
- package/bin/_lib_aggregators.py +18 -5
- package/bin/_lib_doctor.py +39 -0
- package/bin/_lib_render.py +43 -19
- package/bin/cctally +580 -393
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.15.0] - 2026-05-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally claude <cmd>` and `cctally codex <cmd>` subgroup commands** let you paste ccusage's hierarchical syntax verbatim — `cctally claude {daily,monthly,weekly,session,blocks}` is a drop-in for `ccusage claude <cmd>`, and `cctally codex {daily,monthly,session,weekly}` for `ccusage codex <cmd>` (`codex weekly` is a cctally extension; upstream has none). Each subgroup leaf routes to the exact same engine as its flat form, so the table / `--json` / exit code are byte-identical — only `--help` adds a one-line alias/canonical cross-reference. The flat forms (`cctally daily`, `cctally codex-daily`, …) remain fully supported as back-compat aliases, with no deprecation warning. (#86)
|
|
12
|
+
|
|
13
|
+
## [1.14.0] - 2026-05-26
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Running `cctally` from a git checkout now uses a separate `~/.local/share/cctally-dev/` data dir instead of the installed copy's `~/.local/share/cctally/`, so developing against the source tree can no longer corrupt the production instance.** Previously both the source checkout and the npm/brew-installed copy resolved every runtime path from `~/.local/share/cctally/`, so a single dev run that advanced the schema would trip a version mismatch on the still-installed prod binary (and on its background Claude Code hooks) — and vice versa. A checkout is now auto-detected (its `bin/` parent contains a `.git` directory or file, which also covers worktrees) and transparently relocated to `cctally-dev/`; the npm and brew copies ship without `.git`, so installed users are byte-for-byte unaffected. The real Claude session JSONL, `~/.claude/settings.json`, and OAuth credentials stay shared read-only, so dev cost numbers remain real. `cctally doctor` now reports whether it is the installed copy or a dev checkout plus the resolved data dir, `cctally --version` shows a dev-mode marker, and `cctally setup` refuses to wire a dev checkout into `~/.claude/settings.json` (read-only `--status`/`--dry-run` still work) unless given `--force-dev`. Set `CCTALLY_DATA_DIR` to point the data dir somewhere explicit (e.g. a distinct dir per feature branch).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **`cctally session` `totalTokens` (the table column, the `--breakdown` per-model sub-rows, and the `--json` per-session + `totals` fields) now sums all four token components — input + output + cache create + cache read — matching `daily`/`monthly` and upstream `ccusage` v20.** Previously it counted input + output only (the original Spec A2.8 convention), which left the session roll-up ~99% below `ccusage` v20 even though the four component token fields and cost already matched within rounding. The `--json` `totalTokens` field name and shape are unchanged — only the value widened to include cache, so a consumer that previously read it as input+output will now see the cache-inclusive figure. `codex-session` deliberately keeps input + output because Codex `inputTokens` is already cache-inclusive (LiteLLM convention), so it already reports the same "all tokens processed" total. (#104)
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **`cctally codex-session` no longer misaligns its table on narrow terminals (including the default 120-column width) or under `--compact`.** On any terminal narrower than the table's natural width the scale-down branch could shrink a column below its header-label width, and headers like `Reasoning`, `Cache Read`, `Total Tokens` and `Last Activity` are padded — never truncated — in the header render, so the header row grew wider than the box border and the grid broke. Column widths now mirror the `session` and `project` tables via the shared scale-down policy: numeric columns keep their full value (never ellipsis-truncated), header and text labels ellipsize to fit, and every box-drawing line shares one width. (#99)
|
|
23
|
+
- **`cctally`'s `stats.db` write paths are hardened against a `SQLITE_BUSY_SNAPSHOT` "database is locked" crash under concurrent multi-process use** (multiple dashboards plus background `record-usage` / `hook-tick`, magnified by worktrees that share one `~/.local/share/cctally/stats.db`). The one-shot `five_hour_blocks` historical backfill ran a deferred transaction that read its source rows before its first write, so a competing commit landing in that window raised "database is locked" *instantly* — a `busy_timeout` can never absorb that case — and rolled the whole backfill back; it and the live 5h-block upsert now take the write lock up front via `BEGIN IMMEDIATE`. Contrary to the issue's original framing, `busy_timeout` was never the missing piece: `sqlite3.connect()`'s default `timeout=5.0` already gives every `stats.db` open a 5s retry window, so the real fix is acquiring the write lock before the first read so the busy handler can actually wait. Regression: `tests/test_stats_db_busy_timeout.py`. (#87)
|
|
24
|
+
- **`cctally`'s cache-rebuild migration can no longer corrupt session history when it runs concurrently with a cache ingest.** The cache-`db` migration that wipes and recomputes derived state now acquires the same `cache.db.lock` `fcntl` flock that `sync_cache` holds — in one consistent `fcntl`→SQLite acquisition order — before its `BEGIN IMMEDIATE`, so the wipe and a concurrent ingest walk are mutually exclusive and the partial-walk straddle is structurally impossible; under contention the migration defers and retries on the next open rather than interleaving. (#105)
|
|
25
|
+
- **`cctally refresh-usage` no longer crashes when the current 5-hour window is inactive.** When the OAuth usage payload reports the 5h window with a `null` `resets_at` (no active window), `refresh-usage` previously fed that missing reset timestamp into 5h-window-key derivation and raised; the inactive window is now dropped instead.
|
|
26
|
+
|
|
8
27
|
## [1.13.0] - 2026-05-25
|
|
9
28
|
|
|
10
29
|
### Added
|
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ For status-line integration, alerts, and configuration, see [docs/installation.m
|
|
|
113
113
|
|
|
114
114
|
## What cctally adds
|
|
115
115
|
|
|
116
|
-
`cctally` started as a local-first replacement for [`ccusage`](https://github.com/ryoppippi/ccusage), and it stays compatible at the level of common CLI flows (`daily`, `monthly`, `weekly`, `session`, `blocks`). Beyond that, it adds:
|
|
116
|
+
`cctally` started as a local-first replacement for [`ccusage`](https://github.com/ryoppippi/ccusage), and it stays compatible at the level of common CLI flows (`daily`, `monthly`, `weekly`, `session`, `blocks`). Paste from ccusage verbatim: `cctally claude <cmd>` is a drop-in for `ccusage claude <cmd>` (and `cctally codex <cmd>` for `ccusage codex <cmd>`), with the flat forms (`cctally daily`, `cctally codex-daily`, …) kept as aliases. Beyond that, it adds:
|
|
117
117
|
|
|
118
118
|
- **Live web dashboard.** Nine-panel SSE-driven view at `localhost:8789` (current week, forecast, trend, sessions, weekly, monthly, blocks, daily, recent alerts), with per-panel modals, a mobile layout, threshold alerts, and a settings drawer.
|
|
119
119
|
- **TUI live mode.** The same data inside your terminal (`cctally tui`; requires the optional `rich` package).
|
|
@@ -123,7 +123,7 @@ For status-line integration, alerts, and configuration, see [docs/installation.m
|
|
|
123
123
|
- **5-hour block analytics.** Per-block usage with model and project breakdowns (`cctally five-hour-blocks --breakdown=model`).
|
|
124
124
|
- **Time-window diff.** Compare two windows with model and project decomposition (`cctally diff`).
|
|
125
125
|
- **Project rollup.** Usage by Git project (`cctally project`).
|
|
126
|
-
- **Codex parity.**
|
|
126
|
+
- **Codex parity.** `cctally codex daily / monthly / session` are drop-ins for `ccusage codex daily / monthly / session`; the flat `codex-*` forms (drop-ins for the standalone `ccusage-codex` binary) remain as aliases, plus an added `cctally codex weekly` / `cctally codex-weekly` rollup (upstream has no `codex weekly`).
|
|
127
127
|
- **Persistent SQLite.** Week-over-week comparisons survive across runs.
|
|
128
128
|
|
|
129
129
|
**On speed.** Pricing is embedded and computed at query time from a delta-tail SQLite cache (`~/.local/share/cctally/cache.db`), with no shell-outs. First-table latency on 30 days of session data: **~2.6s (cctally) vs ~31s (ccusage)**, about 12× faster. Measured by `bench/cctally-vs-ccusage.sh` on macOS arm64, 2026-05-05; your numbers will vary.[^bench]
|
package/bin/_cctally_core.py
CHANGED
|
@@ -58,7 +58,7 @@ def _init_paths_from_env() -> None:
|
|
|
58
58
|
break tests that cached the module object via a top-level
|
|
59
59
|
`import _cctally_core`).
|
|
60
60
|
"""
|
|
61
|
-
global APP_DIR, LEGACY_APP_DIR, LOG_DIR
|
|
61
|
+
global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
|
|
62
62
|
global DB_PATH, CACHE_DB_PATH
|
|
63
63
|
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CONFIG_LOCK_PATH
|
|
64
64
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
@@ -70,7 +70,23 @@ def _init_paths_from_env() -> None:
|
|
|
70
70
|
global CLAUDE_PROJECTS_DIR
|
|
71
71
|
|
|
72
72
|
home = pathlib.Path.home()
|
|
73
|
-
|
|
73
|
+
|
|
74
|
+
# Dev-instance isolation (docs/superpowers/specs/2026-05-26-dev-instance-
|
|
75
|
+
# isolation-design.md). Resolve the APP_DIR base first; all other path
|
|
76
|
+
# constants derive from it. First match wins:
|
|
77
|
+
# 1. explicit CCTALLY_DATA_DIR override (also the test/harness pin)
|
|
78
|
+
# 2. auto-detected dev checkout -> cctally-dev (sets DEV_MODE)
|
|
79
|
+
# 3. prod default (byte-identical to pre-feature behavior)
|
|
80
|
+
_data_dir_override = os.environ.get("CCTALLY_DATA_DIR", "").strip()
|
|
81
|
+
if _data_dir_override:
|
|
82
|
+
APP_DIR = pathlib.Path(_data_dir_override).expanduser()
|
|
83
|
+
DEV_MODE = False
|
|
84
|
+
elif _is_dev_checkout():
|
|
85
|
+
APP_DIR = home / ".local" / "share" / "cctally-dev"
|
|
86
|
+
DEV_MODE = True
|
|
87
|
+
else:
|
|
88
|
+
APP_DIR = home / ".local" / "share" / "cctally"
|
|
89
|
+
DEV_MODE = False
|
|
74
90
|
LEGACY_APP_DIR = home / ".local" / "share" / "ccusage-subscription"
|
|
75
91
|
LOG_DIR = APP_DIR / "logs"
|
|
76
92
|
|
|
@@ -122,6 +138,27 @@ def _init_paths_from_env() -> None:
|
|
|
122
138
|
CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
|
|
123
139
|
|
|
124
140
|
|
|
141
|
+
def _repo_root() -> pathlib.Path:
|
|
142
|
+
"""Repo root when running from a source checkout: this file lives at
|
|
143
|
+
``<repo>/bin/_cctally_core.py``, so the root is two parents up. Factored
|
|
144
|
+
out as the single monkeypatch seam for the dev-mode tests."""
|
|
145
|
+
return pathlib.Path(__file__).resolve().parent.parent
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_dev_checkout() -> bool:
|
|
149
|
+
"""True iff running from a git checkout (a ``.git`` entry at the repo
|
|
150
|
+
root — a directory for a main checkout, a file for a worktree) AND the
|
|
151
|
+
test/harness suppressor ``CCTALLY_DISABLE_DEV_AUTODETECT`` is unset.
|
|
152
|
+
|
|
153
|
+
Deliberately INDEPENDENT of ``CCTALLY_DATA_DIR``: this predicate gates
|
|
154
|
+
the ``setup`` guard (which protects WHICH BINARY gets wired into
|
|
155
|
+
~/.claude/settings.json), not the data-dir relocation. The npm/brew
|
|
156
|
+
install copies ship without ``.git`` so they never read True."""
|
|
157
|
+
if os.environ.get("CCTALLY_DISABLE_DEV_AUTODETECT"):
|
|
158
|
+
return False
|
|
159
|
+
return (_repo_root() / ".git").exists()
|
|
160
|
+
|
|
161
|
+
|
|
125
162
|
_init_paths_from_env()
|
|
126
163
|
|
|
127
164
|
|
|
@@ -494,6 +531,17 @@ def open_db() -> sqlite3.Connection:
|
|
|
494
531
|
conn.row_factory = sqlite3.Row
|
|
495
532
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
496
533
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
534
|
+
# Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
|
|
535
|
+
# sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
|
|
536
|
+
# so this is not a behavior change — it makes the multi-writer retry window
|
|
537
|
+
# an explicit contract beside the WAL pragmas instead of an inherited
|
|
538
|
+
# default a reader has to know about. NOTE: busy_timeout does NOT absorb
|
|
539
|
+
# SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
|
|
540
|
+
# invalidated by a competing commit raises "database is locked" instantly,
|
|
541
|
+
# bypassing the busy handler). The write paths defend against that by taking
|
|
542
|
+
# the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
|
|
543
|
+
# first DML. See cctally-dev#87.
|
|
544
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
497
545
|
conn.execute(
|
|
498
546
|
"""
|
|
499
547
|
CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
|
package/bin/_cctally_db.py
CHANGED
|
@@ -62,6 +62,7 @@ from __future__ import annotations
|
|
|
62
62
|
import argparse
|
|
63
63
|
import datetime as dt
|
|
64
64
|
import enum
|
|
65
|
+
import fcntl
|
|
65
66
|
import json
|
|
66
67
|
import os
|
|
67
68
|
import pathlib
|
|
@@ -2370,6 +2371,10 @@ def _recompute_banner_should_emit(
|
|
|
2370
2371
|
confuses scripted pipelines. Banner still lands on the
|
|
2371
2372
|
next interactive non-report command (``report``,
|
|
2372
2373
|
``weekly``, ``percent-breakdown``, etc.) once on upgrade.
|
|
2374
|
+
Subgroup forms (``cctally claude/codex <cmd>``, issue #86
|
|
2375
|
+
Session B) carry the source group in ``argv[1]`` and the
|
|
2376
|
+
leaf in ``argv[2]``; we resolve the leaf so suppression is
|
|
2377
|
+
byte-identical to the flat alias.
|
|
2373
2378
|
|
|
2374
2379
|
Returns True iff the banner should be printed. Defensive: any
|
|
2375
2380
|
error reading ``sys.argv`` falls back to "don't print" — silence
|
|
@@ -2387,6 +2392,14 @@ def _recompute_banner_should_emit(
|
|
|
2387
2392
|
return False
|
|
2388
2393
|
try:
|
|
2389
2394
|
argv1 = sys.argv[1] if len(sys.argv) > 1 else None
|
|
2395
|
+
# Subgroup forms (`cctally claude <cmd>` / `cctally codex <cmd>`) carry
|
|
2396
|
+
# the source group in argv[1]; the suppression key is the leaf command
|
|
2397
|
+
# in argv[2]. Resolve it so the recompute banner suppresses identically
|
|
2398
|
+
# to the flat alias (issue #86 Session B; matches the args.command leaf
|
|
2399
|
+
# resolution used by the error-sentinel banner). Purely additive — flat
|
|
2400
|
+
# invocations (argv1 not in {claude,codex}) are byte-identical to before.
|
|
2401
|
+
if argv1 in ("claude", "codex") and len(sys.argv) > 2:
|
|
2402
|
+
argv1 = sys.argv[2]
|
|
2390
2403
|
except Exception:
|
|
2391
2404
|
argv1 = None
|
|
2392
2405
|
if argv1 in _BANNER_SUPPRESSED_COMMANDS:
|
|
@@ -2416,6 +2429,35 @@ def _001_banner_should_emit(conn: sqlite3.Connection) -> bool:
|
|
|
2416
2429
|
return _recompute_banner_should_emit(data_present=row is not None)
|
|
2417
2430
|
|
|
2418
2431
|
|
|
2432
|
+
def _cache_db_lock_path_for_conn(conn: sqlite3.Connection) -> "pathlib.Path | None":
|
|
2433
|
+
"""Return the fcntl lock-file path for the cache.db a connection is
|
|
2434
|
+
attached to — ``<main-db-file>.lock`` — or ``None`` for a path-less
|
|
2435
|
+
(``:memory:`` / temp) connection.
|
|
2436
|
+
|
|
2437
|
+
Derived from the LIVE connection (``PRAGMA database_list``) rather than
|
|
2438
|
+
the ``CACHE_LOCK_PATH`` constant so it tracks whatever cache.db the
|
|
2439
|
+
handler was handed: production uses ``APP_DIR/cache.db`` whose sibling
|
|
2440
|
+
is exactly ``CACHE_LOCK_PATH`` (the lock ``sync_cache`` opens — the
|
|
2441
|
+
``CACHE_LOCK_PATH == <CACHE_DB_PATH>.lock`` identity is asserted by
|
|
2442
|
+
``tests/test_migration_gate_concurrency.py``), while tests follow their
|
|
2443
|
+
tmp cache.db so no real-home lock is ever touched. A path-less
|
|
2444
|
+
connection has no sibling lock file and no cross-process concurrency to
|
|
2445
|
+
guard, so the caller skips locking.
|
|
2446
|
+
"""
|
|
2447
|
+
try:
|
|
2448
|
+
rows = conn.execute("PRAGMA database_list").fetchall()
|
|
2449
|
+
except sqlite3.DatabaseError:
|
|
2450
|
+
return None
|
|
2451
|
+
for row in rows:
|
|
2452
|
+
# cache.db connection has no row_factory -> tuple (seq, name, file).
|
|
2453
|
+
if row[1] == "main":
|
|
2454
|
+
db_file = row[2]
|
|
2455
|
+
if not db_file:
|
|
2456
|
+
return None # :memory: / temp -> no sibling lock file
|
|
2457
|
+
return pathlib.Path(str(db_file) + ".lock")
|
|
2458
|
+
return None
|
|
2459
|
+
|
|
2460
|
+
|
|
2419
2461
|
@cache_migration("001_dedup_highest_wins")
|
|
2420
2462
|
def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
|
|
2421
2463
|
"""One-time re-ingest of session_entries with corrected msg_id+req_id dedup.
|
|
@@ -2464,6 +2506,55 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
|
|
|
2464
2506
|
handler time anyway. Interactive surfaces (``report``,
|
|
2465
2507
|
``weekly``, ``percent-breakdown``, etc.) still see it once.
|
|
2466
2508
|
"""
|
|
2509
|
+
# #105 — mutual exclusion with ``sync_cache``. Acquire the SAME
|
|
2510
|
+
# ``cache.db.lock`` fcntl flock ``sync_cache`` holds for its entire
|
|
2511
|
+
# walk, BEFORE the ``BEGIN IMMEDIATE`` below. Both paths therefore
|
|
2512
|
+
# acquire fcntl -> SQLite write lock in ONE consistent order, so there
|
|
2513
|
+
# is no opposite-order deadlock (the hazard that deferred this fix:
|
|
2514
|
+
# SQLite-then-fcntl in 001 vs fcntl-then-SQLite in sync_cache). With
|
|
2515
|
+
# the lock held across the wipe, 001's destructive DELETEs can never
|
|
2516
|
+
# interleave a ``sync_cache`` walk: a sync runs entirely before 001
|
|
2517
|
+
# (then 001 wipes ``session_files`` so the next sync re-ingests from
|
|
2518
|
+
# offset 0) or entirely after (reading an empty post-wipe baseline).
|
|
2519
|
+
# That makes the compound straddle — a sync reading its ``existing``
|
|
2520
|
+
# baseline pre-wipe, then committing a full-size ``session_files`` row
|
|
2521
|
+
# whose pre-wipe prefix 001 just deleted — structurally impossible.
|
|
2522
|
+
#
|
|
2523
|
+
# On contention (a sync is mid-walk) we DEFER via ``MigrationGateNotMet``
|
|
2524
|
+
# BEFORE touching any data: the cache stays fully consistent, the
|
|
2525
|
+
# dispatcher records 001 as still-pending (no error log, no banner) and
|
|
2526
|
+
# retries it on the next open — matching ``sync_cache``'s own
|
|
2527
|
+
# non-blocking LOCK_NB-and-bail and the framework's "defer is the safe
|
|
2528
|
+
# side" contract. 008/009/010 already defer while 001 is pending, so the
|
|
2529
|
+
# system stays safe until a non-contended instant applies it.
|
|
2530
|
+
lock_path = _cache_db_lock_path_for_conn(conn)
|
|
2531
|
+
lock_fh = None
|
|
2532
|
+
if lock_path is not None:
|
|
2533
|
+
lock_fh = open(lock_path, "w")
|
|
2534
|
+
try:
|
|
2535
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2536
|
+
except BlockingIOError:
|
|
2537
|
+
lock_fh.close()
|
|
2538
|
+
raise MigrationGateNotMet(
|
|
2539
|
+
"cache.db.lock held by a concurrent sync_cache; deferring "
|
|
2540
|
+
"cache 001 dedup wipe (#105)"
|
|
2541
|
+
)
|
|
2542
|
+
try:
|
|
2543
|
+
_001_dedup_highest_wins_locked(conn)
|
|
2544
|
+
finally:
|
|
2545
|
+
if lock_fh is not None:
|
|
2546
|
+
try:
|
|
2547
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
2548
|
+
except OSError:
|
|
2549
|
+
pass
|
|
2550
|
+
lock_fh.close()
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
def _001_dedup_highest_wins_locked(conn: sqlite3.Connection) -> None:
|
|
2554
|
+
"""Body of cache 001, run with the ``cache.db.lock`` flock already held
|
|
2555
|
+
(or skipped for a path-less connection). Split from the public handler
|
|
2556
|
+
so the lock acquire/release wraps the whole wipe (#105); see
|
|
2557
|
+
``_001_dedup_highest_wins`` for the lock-ordering rationale."""
|
|
2467
2558
|
if _001_banner_should_emit(conn):
|
|
2468
2559
|
eprint(
|
|
2469
2560
|
"[cctally] Re-ingesting Claude session history with "
|
package/bin/_cctally_record.py
CHANGED
|
@@ -799,7 +799,13 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
|
|
|
799
799
|
# mid-sequence failure doesn't leave the prior block closed
|
|
800
800
|
# without the current block opened/updated.
|
|
801
801
|
now_iso = now_utc_iso()
|
|
802
|
-
|
|
802
|
+
# BEGIN IMMEDIATE (not deferred): the first DML below is a write (the
|
|
803
|
+
# close-older UPDATE), so this transaction already takes the write lock
|
|
804
|
+
# up front today. Stating IMMEDIATE makes that the explicit contract —
|
|
805
|
+
# a future edit that slips a SELECT before the first write here cannot
|
|
806
|
+
# silently reintroduce a SQLITE_BUSY_SNAPSHOT crash (busy_timeout does
|
|
807
|
+
# not absorb that). See cctally-dev#87.
|
|
808
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
803
809
|
try:
|
|
804
810
|
# Step 5: close any STRICTLY OLDER open block. `<` not `!=`
|
|
805
811
|
# — record-usage runs in parallel via background hook-tick &
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -577,7 +577,12 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
577
577
|
five_resets_iso = None
|
|
578
578
|
five_resets_epoch = None
|
|
579
579
|
warnings: list = []
|
|
580
|
-
|
|
580
|
+
# An inactive 5h window arrives as `resets_at: null` (key present, value
|
|
581
|
+
# null). Require a string here — mirrors the seven_day guard in
|
|
582
|
+
# _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
|
|
583
|
+
# a malformed (non-null) string still degrades via the except below.
|
|
584
|
+
if (five is not None and "utilization" in five
|
|
585
|
+
and isinstance(five.get("resets_at"), str)):
|
|
581
586
|
try:
|
|
582
587
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
583
588
|
five_resets_iso = five["resets_at"]
|
|
@@ -778,7 +783,12 @@ def _hook_tick_oauth_refresh(
|
|
|
778
783
|
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
779
784
|
five_pct: float | None = None
|
|
780
785
|
five_resets_epoch: int | None = None
|
|
781
|
-
|
|
786
|
+
# An inactive 5h window arrives as `resets_at: null` (key present, value
|
|
787
|
+
# null). Require a string here — mirrors the seven_day guard in
|
|
788
|
+
# _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
|
|
789
|
+
# a malformed (non-null) string still degrades via the except below.
|
|
790
|
+
if (five is not None and "utilization" in five
|
|
791
|
+
and isinstance(five.get("resets_at"), str)):
|
|
782
792
|
try:
|
|
783
793
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
784
794
|
five_resets_epoch = _iso_to_epoch(five["resets_at"])
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -77,6 +77,39 @@ from _cctally_core import (
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# Dev-instance isolation (§3): refusal message when `cctally setup` is run
|
|
81
|
+
# from a git checkout without --force-dev. {data_dir} is the resolved
|
|
82
|
+
# APP_DIR for context (cctally-dev in plain dev mode, the override path if
|
|
83
|
+
# CCTALLY_DATA_DIR was set — the guard keys on _is_dev_checkout(), not the
|
|
84
|
+
# data dir, so the override still cannot rewrite prod's hooks).
|
|
85
|
+
_DEV_SETUP_REFUSAL_MSG = (
|
|
86
|
+
"cctally setup: refusing to run from a dev checkout (data dir: {data_dir}).\n"
|
|
87
|
+
"This would rewrite the hooks in ~/.claude/settings.json that point at your\n"
|
|
88
|
+
"installed (prod) cctally. Run setup from the installed binary instead, or\n"
|
|
89
|
+
"pass --force-dev to override (e.g. to install dev-pointing hooks on purpose)."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Dev-instance isolation (§3, P2): warning when `--force-dev` installs hooks
|
|
94
|
+
# while CCTALLY_DATA_DIR is set. The hook command saved into settings.json is
|
|
95
|
+
# just `<binary> hook-tick` — it does NOT carry the override env. A hook fire
|
|
96
|
+
# that doesn't inherit the override (GUI-launched Claude, a different shell)
|
|
97
|
+
# resolves APP_DIR via dev-checkout auto-detect ({autodetect_dir}), while
|
|
98
|
+
# interactive runs in this shell use {override_dir} — silently splitting one
|
|
99
|
+
# intended instance across two DBs. CCTALLY_DATA_DIR is an interactive-only
|
|
100
|
+
# hatch (spec "Out of scope / accepted"); baking it into the global hook
|
|
101
|
+
# command would persist a transient path machine-wide, so we warn instead.
|
|
102
|
+
_DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING = (
|
|
103
|
+
"cctally setup: warning: installing hooks with --force-dev while "
|
|
104
|
+
"CCTALLY_DATA_DIR is set.\n"
|
|
105
|
+
" Interactive runs in this shell use: {override_dir}\n"
|
|
106
|
+
" Background hook fires (no env inherited) will use: {autodetect_dir}\n"
|
|
107
|
+
"The hook command can't carry CCTALLY_DATA_DIR, so these two paths split "
|
|
108
|
+
"your\ndata across separate DBs. CCTALLY_DATA_DIR is an interactive-only "
|
|
109
|
+
"override."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
80
113
|
# ── settings.json hook surgery ─────────────────────────────────────────
|
|
81
114
|
|
|
82
115
|
|
|
@@ -1818,6 +1851,53 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1818
1851
|
|
|
1819
1852
|
|
|
1820
1853
|
def cmd_setup(args: argparse.Namespace) -> int:
|
|
1854
|
+
# Dev-instance isolation (§3): refuse the MUTATING modes (install +
|
|
1855
|
+
# uninstall) when run from a git checkout, unless --force-dev. Those
|
|
1856
|
+
# rewrite ~/.claude/settings.json (prod's hooks), which is NOT under
|
|
1857
|
+
# APP_DIR — from the dev checkout this would repoint prod's hooks at the
|
|
1858
|
+
# dev binary or remove them. --status / --dry-run are read-only previews
|
|
1859
|
+
# (they never write settings.json) and stay usable from a checkout, so
|
|
1860
|
+
# the guard is scoped to the write modes only. The three mode flags are a
|
|
1861
|
+
# mutually-exclusive argparse group, so the write modes (uninstall +
|
|
1862
|
+
# default install) are exactly the complement of {status, dry_run}.
|
|
1863
|
+
# Keyed on _is_dev_checkout() (NOT DEV_MODE / the cctally-dev path
|
|
1864
|
+
# string), so a per-branch CCTALLY_DATA_DIR override relocates the data
|
|
1865
|
+
# dir but still cannot rewrite prod's hooks (the F1 fix). The test
|
|
1866
|
+
# suppressor forces _is_dev_checkout() False, so the setup tests +
|
|
1867
|
+
# golden harness behave exactly like prod.
|
|
1868
|
+
mode_is_mutating = not (
|
|
1869
|
+
getattr(args, "status", False) or getattr(args, "dry_run", False)
|
|
1870
|
+
)
|
|
1871
|
+
if (
|
|
1872
|
+
mode_is_mutating
|
|
1873
|
+
and _cctally_core._is_dev_checkout()
|
|
1874
|
+
and not getattr(args, "force_dev", False)
|
|
1875
|
+
):
|
|
1876
|
+
eprint(_DEV_SETUP_REFUSAL_MSG.format(data_dir=_cctally_core.APP_DIR))
|
|
1877
|
+
return 2
|
|
1878
|
+
# P2: --force-dev install on a checkout with CCTALLY_DATA_DIR set splits
|
|
1879
|
+
# interactive runs (override dir) from background hook fires (auto-detect
|
|
1880
|
+
# dir, since the saved hook command can't carry the override env). Only
|
|
1881
|
+
# the install path writes hooks, so scope the warning to it (uninstall
|
|
1882
|
+
# removes hooks; --status/--dry-run don't write). Fires only on the
|
|
1883
|
+
# doubly-rare --force-dev + CCTALLY_DATA_DIR combination.
|
|
1884
|
+
is_install = not (
|
|
1885
|
+
getattr(args, "status", False)
|
|
1886
|
+
or getattr(args, "dry_run", False)
|
|
1887
|
+
or getattr(args, "uninstall", False)
|
|
1888
|
+
)
|
|
1889
|
+
override_dir = os.environ.get("CCTALLY_DATA_DIR", "").strip()
|
|
1890
|
+
if (
|
|
1891
|
+
is_install
|
|
1892
|
+
and _cctally_core._is_dev_checkout()
|
|
1893
|
+
and getattr(args, "force_dev", False)
|
|
1894
|
+
and override_dir
|
|
1895
|
+
):
|
|
1896
|
+
autodetect_dir = pathlib.Path.home() / ".local" / "share" / "cctally-dev"
|
|
1897
|
+
eprint(_DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING.format(
|
|
1898
|
+
override_dir=pathlib.Path(override_dir).expanduser(),
|
|
1899
|
+
autodetect_dir=autodetect_dir,
|
|
1900
|
+
))
|
|
1821
1901
|
# Migration flags are install-mode-only. Reject combinations with
|
|
1822
1902
|
# --status or --uninstall (per spec Section 2 mode×flag matrix). The
|
|
1823
1903
|
# mutex group on the parser already prevents both flags being set
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -590,7 +590,13 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
|
|
|
590
590
|
cached_input_tokens=s["cached_input"],
|
|
591
591
|
output_tokens=s["output"],
|
|
592
592
|
reasoning_output_tokens=s["reasoning"],
|
|
593
|
-
|
|
593
|
+
# Codex `input` is cache-inclusive (LiteLLM convention; see the
|
|
594
|
+
# "Codex token semantics" gotcha in CLAUDE.md) and `output`
|
|
595
|
+
# subsumes reasoning, so `input + output` already counts ALL
|
|
596
|
+
# tokens processed — the same "all tokens" semantic the Claude
|
|
597
|
+
# session roll-up reaches via input+output+cache (issue #104).
|
|
598
|
+
# Adding cache here would double-count. Matches upstream.
|
|
599
|
+
total_tokens=s["input"] + s["output"],
|
|
594
600
|
cost_usd=s["cost"],
|
|
595
601
|
models=list(s["models_order"]),
|
|
596
602
|
model_breakdowns=model_breakdowns,
|
|
@@ -695,10 +701,17 @@ def _aggregate_claude_sessions(
|
|
|
695
701
|
[sess["models"][m] for m in sess["models_order"]],
|
|
696
702
|
key=lambda mb: -mb["cost"],
|
|
697
703
|
)
|
|
698
|
-
#
|
|
699
|
-
# cache
|
|
700
|
-
#
|
|
701
|
-
|
|
704
|
+
# Issue #104: Total Tokens sums ALL four components (input + output
|
|
705
|
+
# + cache create + cache read), matching `daily`/`monthly` and
|
|
706
|
+
# upstream ccusage v20. (Supersedes the original Spec A2.8
|
|
707
|
+
# input+output-only convention.) The `codex-session` parallel is
|
|
708
|
+
# preserved at the SEMANTIC level — both report "all tokens
|
|
709
|
+
# processed" — even though its surface formula stays `input+output`
|
|
710
|
+
# (Codex `input_tokens` is already cache-inclusive; see line ~593).
|
|
711
|
+
total_tokens = (
|
|
712
|
+
sess["input"] + sess["output"]
|
|
713
|
+
+ sess["cache_create"] + sess["cache_read"]
|
|
714
|
+
)
|
|
702
715
|
results.append(ClaudeSessionUsage(
|
|
703
716
|
session_id=sess["session_id"],
|
|
704
717
|
project_path=sess["project_path"],
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -94,6 +94,15 @@ class DoctorState:
|
|
|
94
94
|
# Meta
|
|
95
95
|
now_utc: dt.datetime
|
|
96
96
|
cctally_version: str
|
|
97
|
+
# Dev-instance isolation (2026-05-26): which data dir this process
|
|
98
|
+
# resolved, and whether it was via dev-checkout auto-detect.
|
|
99
|
+
# `is_dev_checkout` is the binary-location fact (running from a git
|
|
100
|
+
# checkout), independent of `dev_mode` (which is False when an explicit
|
|
101
|
+
# CCTALLY_DATA_DIR override won at step 1). The override-on-checkout case
|
|
102
|
+
# is `is_dev_checkout=True, dev_mode=False` — distinct from installed.
|
|
103
|
+
dev_mode: bool
|
|
104
|
+
app_dir: str
|
|
105
|
+
is_dev_checkout: bool = False
|
|
97
106
|
|
|
98
107
|
|
|
99
108
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -212,6 +221,35 @@ def _check_install_legacy_bespoke(s: DoctorState) -> CheckResult:
|
|
|
212
221
|
)
|
|
213
222
|
|
|
214
223
|
|
|
224
|
+
def _check_install_dev_mode(s: DoctorState) -> CheckResult:
|
|
225
|
+
"""Always-present, always-ok: reports the resolved data dir and whether
|
|
226
|
+
this process is a dev-checkout or the installed binary.
|
|
227
|
+
Dev-instance isolation (§4, P3).
|
|
228
|
+
|
|
229
|
+
Three states, not two — `dev_mode` alone collapses the override case:
|
|
230
|
+
- dev_mode → auto-detected checkout (cctally-dev)
|
|
231
|
+
- is_dev_checkout, not dev_mode → checkout + CCTALLY_DATA_DIR override
|
|
232
|
+
- neither → installed (prod)
|
|
233
|
+
Reporting the override case as "installed" was misleading exactly when a
|
|
234
|
+
user runs the per-branch hatch and wants to confirm which instance they
|
|
235
|
+
are on (the binary IS a checkout; setup still refuses it as one)."""
|
|
236
|
+
if s.dev_mode:
|
|
237
|
+
summary = "DEV (auto-detected git checkout)"
|
|
238
|
+
elif s.is_dev_checkout:
|
|
239
|
+
summary = "DEV (git checkout, custom data dir via CCTALLY_DATA_DIR)"
|
|
240
|
+
else:
|
|
241
|
+
summary = "installed"
|
|
242
|
+
return CheckResult(
|
|
243
|
+
id="install.mode", title="Mode",
|
|
244
|
+
severity="ok", summary=summary, remediation=None,
|
|
245
|
+
details={
|
|
246
|
+
"dev_mode": s.dev_mode,
|
|
247
|
+
"is_dev_checkout": s.is_dev_checkout,
|
|
248
|
+
"app_dir": s.app_dir,
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
215
253
|
_REQUIRED_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
216
254
|
|
|
217
255
|
|
|
@@ -840,6 +878,7 @@ def _check_safety_update_available(s: DoctorState) -> CheckResult:
|
|
|
840
878
|
# success-vs-raise transitions.
|
|
841
879
|
_CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
|
|
842
880
|
("install", "Install", (
|
|
881
|
+
("install.mode", "_check_install_dev_mode"),
|
|
843
882
|
("install.symlinks", "_check_install_symlinks"),
|
|
844
883
|
("install.path", "_check_install_path"),
|
|
845
884
|
("install.legacy_snippet", "_check_install_legacy_snippet"),
|
package/bin/_lib_render.py
CHANGED
|
@@ -1006,13 +1006,18 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1006
1006
|
|
|
1007
1007
|
|
|
1008
1008
|
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
1009
|
-
"""Serialize Claude sessions to JSON
|
|
1009
|
+
"""Serialize Claude sessions to JSON (spec A2.8, amended by issue #104).
|
|
1010
1010
|
|
|
1011
1011
|
Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
|
|
1012
1012
|
/ lastActivity ISO strings, modelsUsed, token counts
|
|
1013
1013
|
(input/cacheCreation/cacheRead/output/total), totalCost, modelBreakdowns
|
|
1014
1014
|
(camelCased token field names, cost).
|
|
1015
1015
|
|
|
1016
|
+
`totalTokens` (per-session + totals) sums ALL four token components
|
|
1017
|
+
(input + output + cacheCreation + cacheRead) per issue #104 — matching
|
|
1018
|
+
`daily`/`monthly` and ccusage v20. (The field name/shape is unchanged;
|
|
1019
|
+
only the value definition widened to include cache.)
|
|
1020
|
+
|
|
1016
1021
|
totals: same 6 numeric fields aggregated across sessions.
|
|
1017
1022
|
"""
|
|
1018
1023
|
sess_list: list[dict[str, Any]] = []
|
|
@@ -2118,12 +2123,23 @@ def _render_codex_session_table(
|
|
|
2118
2123
|
# auto-detect so the narrow layout renders regardless of terminal width.
|
|
2119
2124
|
if force_compact or (sum(col_widths) + border_overhead > term_width):
|
|
2120
2125
|
available = term_width - border_overhead
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2126
|
+
# Issue #99 / #102: the prior bare-`8` floor could scale a column
|
|
2127
|
+
# below its header label, and headers are padded (never truncated)
|
|
2128
|
+
# in the header render — so the header row grew wider than the box
|
|
2129
|
+
# border and the grid misaligned on narrow terminals. Mirror the
|
|
2130
|
+
# sibling renderers (`_render_claude_session_table` + the project
|
|
2131
|
+
# renderer) via the shared `_scale_down_col_widths` chokepoint:
|
|
2132
|
+
# numeric columns are protected at their widest DATA value while
|
|
2133
|
+
# text columns (incl. header labels) absorb the squeeze and may
|
|
2134
|
+
# truncate, keeping every box line the same width. Grows the Models
|
|
2135
|
+
# column (index 3) with any slack, preserving prior behavior.
|
|
2136
|
+
data_widths = [0] * num_cols
|
|
2137
|
+
for cells, _rt in raw_rows:
|
|
2138
|
+
for i, (text, _c) in enumerate(cells):
|
|
2139
|
+
data_widths[i] = max(data_widths[i], _max_line_width(text))
|
|
2140
|
+
col_widths = _scale_down_col_widths(
|
|
2141
|
+
col_widths, aligns, data_widths, available, grow_idx=3,
|
|
2142
|
+
)
|
|
2127
2143
|
|
|
2128
2144
|
def _split_cell(text: str) -> list[str]:
|
|
2129
2145
|
return text.split("\n") if text else [""]
|
|
@@ -2165,13 +2181,17 @@ def _render_codex_session_table(
|
|
|
2165
2181
|
|
|
2166
2182
|
out.append(_border_row(TL, T_DOWN, TR))
|
|
2167
2183
|
|
|
2168
|
-
# Header
|
|
2184
|
+
# Header — labels ellipsize like data cells so a column scaled below
|
|
2185
|
+
# its header width stays box-aligned (issue #99 / #102 (a)). Previously
|
|
2186
|
+
# the header padded without truncating, so a label wider than its
|
|
2187
|
+
# scaled column overflowed the box border.
|
|
2169
2188
|
header_cells = [_split_cell(h) for h in headers]
|
|
2170
2189
|
max_h = max(len(c) for c in header_cells)
|
|
2171
2190
|
for li in range(max_h):
|
|
2172
2191
|
parts = [_dim(V)]
|
|
2173
2192
|
for i, cell in enumerate(header_cells):
|
|
2174
2193
|
content = cell[li] if li < len(cell) else ""
|
|
2194
|
+
content = _ellipsize(content, col_widths[i], unicode_ok)
|
|
2175
2195
|
parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
|
|
2176
2196
|
parts.append(_dim(V))
|
|
2177
2197
|
out.append("".join(parts))
|
|
@@ -2187,11 +2207,14 @@ def _render_codex_session_table(
|
|
|
2187
2207
|
parts = [_dim(V)]
|
|
2188
2208
|
for i, (text, cfn) in enumerate(cells):
|
|
2189
2209
|
content = split_cells[i][li] if li < len(split_cells[i]) else ""
|
|
2190
|
-
#
|
|
2210
|
+
# Ellipsis-truncate only TEXT cells. Numeric (right-aligned)
|
|
2211
|
+
# cells are NEVER truncated \u2014 a wrong number is worse than
|
|
2212
|
+
# honest overflow (issue #102 (b)); _scale_down_col_widths
|
|
2213
|
+
# floors numeric columns at their full number width so this
|
|
2214
|
+
# normally never overflows. Mirrors the sibling renderers.
|
|
2191
2215
|
w = col_widths[i]
|
|
2192
|
-
if
|
|
2193
|
-
|
|
2194
|
-
content = content[: max(0, w - len(ell))] + ell
|
|
2216
|
+
if aligns[i] != "right":
|
|
2217
|
+
content = _ellipsize(content, w, unicode_ok)
|
|
2195
2218
|
padded = _pad_cell(content, w, aligns[i])
|
|
2196
2219
|
if cfn is not None:
|
|
2197
2220
|
padded = cfn(padded)
|
|
@@ -2281,9 +2304,10 @@ def _render_claude_session_table(
|
|
|
2281
2304
|
for s in sessions:
|
|
2282
2305
|
short_models = sorted({_short_model_name(m) for m in s.models})
|
|
2283
2306
|
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
2284
|
-
#
|
|
2285
|
-
#
|
|
2286
|
-
|
|
2307
|
+
# Issue #104: Total Tokens = all four components (input + output +
|
|
2308
|
+
# cache), matching daily/monthly + ccusage v20. Read the single
|
|
2309
|
+
# source of truth on the aggregate rather than recomputing.
|
|
2310
|
+
session_total = s.total_tokens
|
|
2287
2311
|
data_cells = [
|
|
2288
2312
|
(_date_cell(s.last_activity), None),
|
|
2289
2313
|
(s.project_path, None),
|
|
@@ -2306,8 +2330,8 @@ def _render_claude_session_table(
|
|
|
2306
2330
|
mb_cc = int(mb["cache_create"])
|
|
2307
2331
|
mb_cr = int(mb["cache_read"])
|
|
2308
2332
|
mb_output = int(mb["output"])
|
|
2309
|
-
#
|
|
2310
|
-
mb_total = mb_input + mb_output
|
|
2333
|
+
# Issue #104: per-model Total Tokens sums all four components.
|
|
2334
|
+
mb_total = mb_input + mb_output + mb_cc + mb_cr
|
|
2311
2335
|
mb_cost = float(mb["cost"])
|
|
2312
2336
|
bd_cells = [
|
|
2313
2337
|
(f"{arrow} {name}", _gray),
|
|
@@ -2328,8 +2352,8 @@ def _render_claude_session_table(
|
|
|
2328
2352
|
tot_cc = sum(s.cache_creation_tokens for s in sessions)
|
|
2329
2353
|
tot_cr = sum(s.cache_read_tokens for s in sessions)
|
|
2330
2354
|
tot_output = sum(s.output_tokens for s in sessions)
|
|
2331
|
-
#
|
|
2332
|
-
tot_tokens =
|
|
2355
|
+
# Issue #104: Total Tokens footer sums all four components.
|
|
2356
|
+
tot_tokens = sum(s.total_tokens for s in sessions)
|
|
2333
2357
|
tot_cost = sum(s.cost_usd for s in sessions)
|
|
2334
2358
|
footer_cells = [
|
|
2335
2359
|
("Total", _yellow),
|