cctally 1.18.0 → 1.19.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 +5 -0
- package/bin/_cctally_cache.py +91 -23
- package/bin/_lib_aggregators.py +68 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +212 -53
- package/bin/cctally +188 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.19.0] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally blocks` (and `cctally claude blocks`) gain the ccusage drop-in flags `-a/--active`, `-r/--recent`, `-t/--token-limit N|max`, and `-n/--session-length N`.** `-a` filters to the single live block and renders it as a "Current Session Block Status" detail box — Block Started (+ "Xh Ym ago" / approximate-start `~` cue when the window has no recorded Anthropic reset), Time Remaining, Current Usage, Burn Rate and Projected Usage (when available), and — only when `-t` is passed explicitly — a Token Limit Status block whose `OK`/`WARNING`/`EXCEEDS LIMIT` color tracks the projected percent; with no active block it prints `No active session block found.` to stdout (JSON: `{"blocks": [], "message": "No active block"}`) and exits 0. `-r` keeps only blocks from the last 3 days plus the active block. `-t N` keys the table's `%`/REMAINING/PROJECTED surface (and the `-a` box's Token Limit Status) to an explicit limit even with no completed history, while `-t max` (the default) derives it from the largest completed block and prints `Using max tokens from previous sessions: N` to stdout (suppressed under `--json`); `--json` additionally gains an additive `tokenLimitStatus` key on active blocks under an explicit positive `-t`. `-n` is accepted for drop-in compatibility but is a no-op — cctally blocks follow Anthropic's real 5-hour resets and are not re-sizable — except `-n <= 0`, which errors (exit 1). cctally's block projection keeps its real-reset formula (a documented third intentional divergence from upstream's entry-span model). (#86)
|
|
12
|
+
|
|
8
13
|
## [1.18.0] - 2026-05-27
|
|
9
14
|
|
|
10
15
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -11,8 +11,8 @@ pipeline.
|
|
|
11
11
|
Holds:
|
|
12
12
|
- ``ProjectKey`` (frozen dataclass) + ``_resolve_project_key`` —
|
|
13
13
|
canonical project bucket identity for the ``project`` subcommand.
|
|
14
|
-
- ``
|
|
15
|
-
Codex JSONL discovery primitives.
|
|
14
|
+
- ``_discover_codex_session_files`` / ``_iter_codex_jsonl_paths`` —
|
|
15
|
+
Codex JSONL discovery primitives (multi-root $CODEX_HOME walk).
|
|
16
16
|
- ``IngestStats`` / ``CodexIngestStats`` (dataclasses), ``_progress_stderr``
|
|
17
17
|
/ ``_progress_codex_stderr`` — ingest progress + per-call telemetry.
|
|
18
18
|
- ``_ensure_session_files_row`` — idempotent backfill of
|
|
@@ -82,7 +82,7 @@ in the sibling graph):
|
|
|
82
82
|
``get_entries``, ``get_claude_session_entries``, ``get_codex_entries``,
|
|
83
83
|
``_resolve_project_key``, ``ProjectKey``, ``IngestStats``,
|
|
84
84
|
``CodexIngestStats``, ``_JoinedClaudeEntry``, ``_ensure_session_files_row``,
|
|
85
|
-
``_discover_codex_session_files``,
|
|
85
|
+
``_discover_codex_session_files``,
|
|
86
86
|
``cmd_cache_sync``, ``_progress_stderr``, ``_progress_codex_stderr``,
|
|
87
87
|
``_collect_entries_direct``, ``_collect_codex_entries_direct``,
|
|
88
88
|
``_direct_parse_claude_session_entries``, ``iter_codex_entries``)
|
|
@@ -107,7 +107,7 @@ import pathlib
|
|
|
107
107
|
import sqlite3
|
|
108
108
|
import sys
|
|
109
109
|
from dataclasses import dataclass, field
|
|
110
|
-
from typing import Any, Callable
|
|
110
|
+
from typing import Any, Callable, Iterator
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _cctally():
|
|
@@ -265,27 +265,54 @@ def _resolve_project_key(
|
|
|
265
265
|
# === Region 2: Codex sessions-dir helpers (was bin/cctally:2072-2099) ===
|
|
266
266
|
|
|
267
267
|
|
|
268
|
-
def
|
|
269
|
-
"""
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
268
|
+
def _iter_codex_jsonl_paths(roots: list[pathlib.Path]) -> Iterator[pathlib.Path]:
|
|
269
|
+
"""Yield each existing *.jsonl under the given roots, de-duped by RESOLVED
|
|
270
|
+
path (first occurrence wins — collapses overlapping/prefix roots and
|
|
271
|
+
symlink/`..` aliases of the same physical file).
|
|
272
|
+
|
|
273
|
+
Pure read: globs + is_file() only, no DB access. Shared by both Codex
|
|
274
|
+
walkers (_discover_codex_session_files and sync_codex_cache) so they stay
|
|
275
|
+
in lock-step on dedup + is_file() ordering.
|
|
276
|
+
"""
|
|
277
|
+
seen: set[pathlib.Path] = set()
|
|
278
|
+
for root in roots:
|
|
279
|
+
for jp in root.glob("**/*.jsonl"):
|
|
280
|
+
# Dedup on the RESOLVED path, not the raw spelling. A symlinked
|
|
281
|
+
# $CODEX_HOME root or an alias entry (`.../.codex`,
|
|
282
|
+
# `.../sub/../.codex`) can glob the same physical file under
|
|
283
|
+
# different spellings; UNIQUE(source_path, line_offset) keys on the
|
|
284
|
+
# string, so distinct spellings would double-ingest (2-3x tokens /
|
|
285
|
+
# cost) on a fresh walk. resolve() collapses the aliases (issue
|
|
286
|
+
# #108). First spelling still wins for the yielded source_path.
|
|
287
|
+
try:
|
|
288
|
+
key = jp.resolve()
|
|
289
|
+
except OSError:
|
|
290
|
+
key = jp # unresolvable (broken symlink, perms) — key on raw
|
|
291
|
+
if key in seen:
|
|
292
|
+
continue
|
|
293
|
+
seen.add(key)
|
|
294
|
+
if jp.is_file():
|
|
295
|
+
yield jp
|
|
274
296
|
|
|
275
297
|
|
|
276
298
|
def _discover_codex_session_files(
|
|
277
299
|
range_start: dt.datetime,
|
|
278
300
|
) -> list[pathlib.Path]:
|
|
279
|
-
"""Glob
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
301
|
+
"""Glob each $CODEX_HOME session root's **/*.jsonl, mtime >= range_start.
|
|
302
|
+
|
|
303
|
+
Iterates _cctally()._codex_session_roots() (multi-root). The "none found"
|
|
304
|
+
notice fires ONLY when there are zero session-root directories at all (the
|
|
305
|
+
multi-root analogue of the old single-dir-missing check) — NOT when roots
|
|
306
|
+
exist but the mtime filter leaves the set empty (that stays silent, as
|
|
307
|
+
today, so narrow-range queries gain no new stderr).
|
|
308
|
+
"""
|
|
309
|
+
roots = _cctally()._codex_session_roots()
|
|
310
|
+
if not roots:
|
|
311
|
+
eprint("[codex] no Codex session directory found")
|
|
283
312
|
return []
|
|
284
313
|
start_ts = range_start.timestamp()
|
|
285
314
|
result: list[pathlib.Path] = []
|
|
286
|
-
for jp in
|
|
287
|
-
if not jp.is_file():
|
|
288
|
-
continue
|
|
315
|
+
for jp in _iter_codex_jsonl_paths(roots):
|
|
289
316
|
try:
|
|
290
317
|
mtime = jp.stat().st_mtime
|
|
291
318
|
except OSError:
|
|
@@ -1190,6 +1217,9 @@ class CodexIngestStats:
|
|
|
1190
1217
|
# ``IngestStats`` (Claude path) which carries an UPSERT and
|
|
1191
1218
|
# therefore counts both new INSERTs and DO UPDATE replacements.
|
|
1192
1219
|
rows_changed: int = 0
|
|
1220
|
+
# Count of cached files dropped because they fall outside the CURRENT
|
|
1221
|
+
# $CODEX_HOME root set (issue #108 — a prior-root purge, not a delta).
|
|
1222
|
+
files_pruned: int = 0
|
|
1193
1223
|
lock_contended: bool = False
|
|
1194
1224
|
|
|
1195
1225
|
|
|
@@ -1243,14 +1273,52 @@ def sync_codex_cache(
|
|
|
1243
1273
|
conn.commit()
|
|
1244
1274
|
eprint("[cache-sync] rebuild: cleared Codex cached entries")
|
|
1245
1275
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
if jp.is_file():
|
|
1251
|
-
paths.append(jp)
|
|
1276
|
+
roots = _cctally()._codex_session_roots()
|
|
1277
|
+
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
1278
|
+
# the per-file loop, where no cache.db write lock may be held.
|
|
1279
|
+
paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
|
|
1252
1280
|
stats.files_total = len(paths)
|
|
1253
1281
|
|
|
1282
|
+
# Scope the cache to the CURRENT root set: drop rows ingested under a
|
|
1283
|
+
# prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
|
|
1284
|
+
# predicate — it reads every row in range — so without this, reusing
|
|
1285
|
+
# the same cache.db across `CODEX_HOME=/A` then `CODEX_HOME=/B` runs
|
|
1286
|
+
# returns A+B instead of just B. Prune every real (absolute) row
|
|
1287
|
+
# outside the current set, even when that set is empty (an empty
|
|
1288
|
+
# current root then prunes the cache to empty): the cache is fully
|
|
1289
|
+
# re-derivable, so honoring the override beats retaining unreachable
|
|
1290
|
+
# rows. Done INSIDE the lock and committed BEFORE the existing-SELECT
|
|
1291
|
+
# + parse loop so no cache.db write lock is held across the read-heavy
|
|
1292
|
+
# ingest (same invariant as the --rebuild clear above). Concurrent
|
|
1293
|
+
# processes with different $CODEX_HOME would prune each other; the
|
|
1294
|
+
# flock serializes them and that is a pathological configuration.
|
|
1295
|
+
if not rebuild: # --rebuild already cleared both tables above
|
|
1296
|
+
current_paths = {str(p) for p in paths}
|
|
1297
|
+
# Only prune ABSOLUTE source_paths. _codex_home_roots() makes
|
|
1298
|
+
# every real root absolute (via .absolute()), so a real ingested
|
|
1299
|
+
# row always stores an absolute str(jp) — INCLUDING a relative
|
|
1300
|
+
# $CODEX_HOME like `./codexA`, which is canonicalized before the
|
|
1301
|
+
# glob. A relative path here is therefore — by construction — a
|
|
1302
|
+
# synthetic baked-cache fixture row (e.g. build-speed-fixtures.py)
|
|
1303
|
+
# with no on-disk JSONL to scope against; pruning it would wipe a
|
|
1304
|
+
# cache meant to be read as-is (issue #108).
|
|
1305
|
+
orphan_paths = [
|
|
1306
|
+
row[0]
|
|
1307
|
+
for row in conn.execute("SELECT path FROM codex_session_files")
|
|
1308
|
+
if row[0] not in current_paths and os.path.isabs(row[0])
|
|
1309
|
+
]
|
|
1310
|
+
if orphan_paths:
|
|
1311
|
+
conn.executemany(
|
|
1312
|
+
"DELETE FROM codex_session_entries WHERE source_path = ?",
|
|
1313
|
+
[(p,) for p in orphan_paths],
|
|
1314
|
+
)
|
|
1315
|
+
conn.executemany(
|
|
1316
|
+
"DELETE FROM codex_session_files WHERE path = ?",
|
|
1317
|
+
[(p,) for p in orphan_paths],
|
|
1318
|
+
)
|
|
1319
|
+
conn.commit()
|
|
1320
|
+
stats.files_pruned = len(orphan_paths)
|
|
1321
|
+
|
|
1254
1322
|
# This SELECT does NOT open an implicit transaction (Python's
|
|
1255
1323
|
# sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
|
|
1256
1324
|
# UPDATE/DELETE/REPLACE statement between here and the per-file
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -375,6 +375,14 @@ class CodexSessionUsage:
|
|
|
375
375
|
models: list[str]
|
|
376
376
|
model_breakdowns: list[dict[str, Any]]
|
|
377
377
|
last_activity: dt.datetime
|
|
378
|
+
# Issue #110: the matched $CODEX_HOME root in home-root form
|
|
379
|
+
# (e.g. "<root>/.codex", or "<root>" for a direct-JSONL root). Used ONLY
|
|
380
|
+
# to disambiguate the displayed / JSON label when two cross-root sessions
|
|
381
|
+
# share the same relative `session_id_path`. "" for the bare-relative
|
|
382
|
+
# fixture form (which cannot collide cross-root). Single-root data leaves
|
|
383
|
+
# every row's `codex_root` constant, so the renderers' collision check
|
|
384
|
+
# never fires and output stays byte-identical.
|
|
385
|
+
codex_root: str = ""
|
|
378
386
|
|
|
379
387
|
|
|
380
388
|
@dataclass
|
|
@@ -531,24 +539,30 @@ def _aggregate_codex_weekly(
|
|
|
531
539
|
def _session_path_parts(source_path: str) -> tuple[str, str, str]:
|
|
532
540
|
"""Return (session_id_path, session_file, directory) from a full path.
|
|
533
541
|
|
|
534
|
-
session_id_path = relative path under
|
|
535
|
-
stripped (e.g. "2025/12/25/rollout-...").
|
|
542
|
+
session_id_path = relative path under the matched $CODEX_HOME session
|
|
543
|
+
root with .jsonl stripped (e.g. "2025/12/25/rollout-...").
|
|
536
544
|
session_file = basename without .jsonl extension.
|
|
537
|
-
directory = relative parent path under
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
+
directory = relative parent path under the matched root.
|
|
546
|
+
|
|
547
|
+
Tries each root in _codex_session_roots() order (the same list/order the
|
|
548
|
+
discovery walkers use, so overlapping/prefix roots resolve to the FIRST
|
|
549
|
+
matching root deterministically); first relative_to() that succeeds wins.
|
|
550
|
+
Falls back to the bare-relative ".codex/sessions/<rest>" fixture form (the
|
|
551
|
+
shape emitted by build-codex-fixtures.py so committed fixture cache.db
|
|
552
|
+
files stay free of maintainer absolute paths), then basename. Direct-JSONL
|
|
553
|
+
roots yield an id relative to <entry> itself (no sessions/ prefix).
|
|
545
554
|
"""
|
|
546
|
-
|
|
555
|
+
roots = _cctally()._codex_session_roots()
|
|
547
556
|
p = pathlib.Path(source_path)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
557
|
+
rel: pathlib.PurePath | None = None
|
|
558
|
+
for root in roots:
|
|
559
|
+
try:
|
|
560
|
+
rel = p.relative_to(root)
|
|
561
|
+
break
|
|
562
|
+
except ValueError:
|
|
563
|
+
continue
|
|
564
|
+
if rel is None:
|
|
565
|
+
# Bare-relative ".codex/sessions/<rest>" (fixture form), else basename.
|
|
552
566
|
# Use PurePosixPath to avoid Windows-style drive parsing on unusual
|
|
553
567
|
# inputs; fixture-emitted paths are always POSIX.
|
|
554
568
|
parts = pathlib.PurePosixPath(source_path).parts
|
|
@@ -560,6 +574,22 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
|
|
|
560
574
|
return str(stem), stem.name, str(stem.parent)
|
|
561
575
|
|
|
562
576
|
|
|
577
|
+
def _codex_home_root_from_prefix(root_prefix: str) -> str:
|
|
578
|
+
"""Normalize the aggregator's `root_prefix` to the matched $CODEX_HOME entry.
|
|
579
|
+
|
|
580
|
+
`root_prefix` is `source_path` with the relative `id_path` tail removed, so a
|
|
581
|
+
Codex-home match looks like "<root>/.codex/sessions/" and a direct-JSONL
|
|
582
|
+
match like "<root>/". Strip the trailing slash and any "/sessions" tail to
|
|
583
|
+
recover the home root the user configured — the unit the issue #110
|
|
584
|
+
disambiguator labels by. The bare-relative fixture prefix ".codex/sessions/"
|
|
585
|
+
normalizes to ".codex" (constant across fixtures, so it never collides).
|
|
586
|
+
"""
|
|
587
|
+
s = root_prefix.rstrip("/")
|
|
588
|
+
if s.endswith("/sessions"):
|
|
589
|
+
s = s[: -len("/sessions")]
|
|
590
|
+
return s
|
|
591
|
+
|
|
592
|
+
|
|
563
593
|
def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
|
|
564
594
|
"""Group by session file path (upstream-compatible).
|
|
565
595
|
|
|
@@ -570,13 +600,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
570
600
|
Per-model breakdowns include `isFallback: bool` — true when the model is
|
|
571
601
|
absent from CODEX_MODEL_PRICING.
|
|
572
602
|
"""
|
|
573
|
-
by_session: dict[str, dict[str, Any]] = {}
|
|
603
|
+
by_session: dict[tuple[str, str], dict[str, Any]] = {}
|
|
574
604
|
for entry in entries:
|
|
575
605
|
id_path, file_name, directory = _session_path_parts(entry.source_path)
|
|
576
|
-
|
|
606
|
+
# Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
|
|
607
|
+
# roots (issue #108). _session_path_parts strips the matched root, so
|
|
608
|
+
# <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
|
|
609
|
+
# path under <rootB> both yield id_path "2026/04/17/rollout-x";
|
|
610
|
+
# grouping on id_path alone would silently merge two distinct sessions
|
|
611
|
+
# (summed tokens, one UUID). Key on (root_prefix, id_path), where
|
|
612
|
+
# root_prefix is source_path with the id_path tail removed. Single-root
|
|
613
|
+
# data — and the bare-relative fixture form — has a constant prefix, so
|
|
614
|
+
# the grouping, insertion order, and every golden stay byte-identical;
|
|
615
|
+
# only a genuine cross-root collision splits into separate rows.
|
|
616
|
+
suffix = id_path + ".jsonl"
|
|
617
|
+
sp = entry.source_path
|
|
618
|
+
root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
|
|
619
|
+
sess = by_session.setdefault((root_prefix, id_path), {
|
|
577
620
|
"session_id_uuid": entry.session_id,
|
|
621
|
+
"session_id_path": id_path,
|
|
578
622
|
"session_file": file_name,
|
|
579
623
|
"directory": directory,
|
|
624
|
+
# Matched $CODEX_HOME root (home-root form) — issue #110 display
|
|
625
|
+
# disambiguator. Derived from the same root_prefix that keys the
|
|
626
|
+
# group, so it's constant per group.
|
|
627
|
+
"codex_root": _codex_home_root_from_prefix(root_prefix),
|
|
580
628
|
"input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
|
|
581
629
|
"cost": 0.0, "models": {}, "models_order": [],
|
|
582
630
|
"last": entry.timestamp,
|
|
@@ -606,7 +654,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
606
654
|
sess["last"] = entry.timestamp
|
|
607
655
|
|
|
608
656
|
result: list[CodexSessionUsage] = []
|
|
609
|
-
for
|
|
657
|
+
for _group_key, s in by_session.items():
|
|
610
658
|
model_breakdowns = [
|
|
611
659
|
{
|
|
612
660
|
"modelName": model,
|
|
@@ -623,7 +671,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
623
671
|
model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
|
|
624
672
|
result.append(CodexSessionUsage(
|
|
625
673
|
session_id=s["session_id_uuid"],
|
|
626
|
-
session_id_path=
|
|
674
|
+
session_id_path=s["session_id_path"],
|
|
627
675
|
session_file=s["session_file"],
|
|
628
676
|
directory=s["directory"],
|
|
629
677
|
input_tokens=s["input"],
|
|
@@ -641,6 +689,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
641
689
|
models=list(s["models_order"]),
|
|
642
690
|
model_breakdowns=model_breakdowns,
|
|
643
691
|
last_activity=s["last"],
|
|
692
|
+
codex_root=s["codex_root"],
|
|
644
693
|
))
|
|
645
694
|
result.sort(key=lambda x: x.last_activity, reverse=True)
|
|
646
695
|
return result
|
package/bin/_lib_blocks.py
CHANGED
|
@@ -36,6 +36,7 @@ import bisect
|
|
|
36
36
|
import datetime as dt
|
|
37
37
|
import json
|
|
38
38
|
import pathlib
|
|
39
|
+
import re
|
|
39
40
|
import sys
|
|
40
41
|
from dataclasses import dataclass
|
|
41
42
|
from typing import Any
|
|
@@ -432,7 +433,42 @@ def _build_activity_block(
|
|
|
432
433
|
)
|
|
433
434
|
|
|
434
435
|
|
|
435
|
-
def
|
|
436
|
+
def _max_completed_block_tokens(blocks: list["Block"]) -> int:
|
|
437
|
+
"""Largest total_tokens among completed (non-gap, non-active) blocks.
|
|
438
|
+
|
|
439
|
+
The auto-derived token-limit baseline for the `%`/REMAINING/PROJECTED
|
|
440
|
+
surface — matches ccusage's `maxTokensFromAll` (computed over all blocks
|
|
441
|
+
before any --recent/--active filtering). Returns 0 when there is no
|
|
442
|
+
completed block with tokens.
|
|
443
|
+
"""
|
|
444
|
+
best = 0
|
|
445
|
+
for b in blocks:
|
|
446
|
+
if not b.is_gap and not b.is_active and b.total_tokens > best:
|
|
447
|
+
best = b.total_tokens
|
|
448
|
+
return best
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _parse_blocks_token_limit(
|
|
452
|
+
raw: "str | None", max_from_completed: int
|
|
453
|
+
) -> "int | None":
|
|
454
|
+
"""Resolve the `-t/--token-limit` value to an int limit or None.
|
|
455
|
+
|
|
456
|
+
Mirrors ccusage `parseTokenLimit`: `None`/`""`/`"max"` → the auto-derived
|
|
457
|
+
`max_from_completed` (or None when it is 0); otherwise replicate JS
|
|
458
|
+
`Number.parseInt(raw, 10)` — leading optional sign + run of digits, stop at
|
|
459
|
+
the first non-digit (`"123abc"`→123, `"12.5"`→12, `"abc"`/`""`→None). A
|
|
460
|
+
non-positive result still returns the int; the caller's `limit > 0` gate
|
|
461
|
+
suppresses the `%` column (same observable result as upstream).
|
|
462
|
+
"""
|
|
463
|
+
if raw is None or raw in ("", "max"):
|
|
464
|
+
return max_from_completed if max_from_completed > 0 else None
|
|
465
|
+
m = re.match(r"\s*([+-]?\d+)", raw)
|
|
466
|
+
return int(m.group(1)) if m else None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _blocks_to_json(
|
|
470
|
+
blocks: list[Block], *, token_limit_status_limit: int | None = None
|
|
471
|
+
) -> str:
|
|
436
472
|
"""Serialize blocks to JSON matching upstream ccusage's output structure."""
|
|
437
473
|
|
|
438
474
|
def _iso_utc(ts: dt.datetime) -> str:
|
|
@@ -470,6 +506,24 @@ def _blocks_to_json(blocks: list[Block]) -> str:
|
|
|
470
506
|
"burnRate": block.burn_rate,
|
|
471
507
|
"projection": block.projection,
|
|
472
508
|
})
|
|
509
|
+
if (token_limit_status_limit is not None
|
|
510
|
+
and token_limit_status_limit > 0
|
|
511
|
+
and not block.is_gap
|
|
512
|
+
and block.is_active and block.projection):
|
|
513
|
+
limit = token_limit_status_limit
|
|
514
|
+
proj_tokens = block.projection["totalTokens"]
|
|
515
|
+
pct = (proj_tokens / limit) * 100.0
|
|
516
|
+
# Keep the exceeds/warning/ok thresholds (>100% / >80%) in sync with
|
|
517
|
+
# the box status ladder in _lib_render._render_active_block_box.
|
|
518
|
+
status = ("exceeds" if proj_tokens > limit
|
|
519
|
+
else "warning" if proj_tokens > limit * 0.8
|
|
520
|
+
else "ok")
|
|
521
|
+
obj["tokenLimitStatus"] = {
|
|
522
|
+
"limit": limit,
|
|
523
|
+
"projectedUsage": proj_tokens,
|
|
524
|
+
"percentUsed": pct,
|
|
525
|
+
"status": status,
|
|
526
|
+
}
|
|
473
527
|
result.append(obj)
|
|
474
528
|
|
|
475
529
|
return json.dumps({"blocks": result}, indent=2)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -530,7 +530,7 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
530
530
|
if count == 0 and not s.codex_jsonl_present:
|
|
531
531
|
return CheckResult(
|
|
532
532
|
id="data.codex_cache", title="Codex cache",
|
|
533
|
-
severity="ok", summary="none (no
|
|
533
|
+
severity="ok", summary="none (no Codex session JSONL found)",
|
|
534
534
|
remediation=None,
|
|
535
535
|
details={"entries": 0, "codex_jsonl_present": False},
|
|
536
536
|
)
|
package/bin/_lib_render.py
CHANGED
|
@@ -225,6 +225,124 @@ def _scale_down_col_widths(
|
|
|
225
225
|
# Optional dependency: zoneinfo.ZoneInfo is referenced only as a string
|
|
226
226
|
# annotation in moved code; no runtime import needed.
|
|
227
227
|
|
|
228
|
+
|
|
229
|
+
def _render_title_banner(title: str, *, unicode_ok: bool, color: bool) -> str:
|
|
230
|
+
"""ccusage-style rounded title banner (the box around a report title).
|
|
231
|
+
|
|
232
|
+
Returns the multi-line banner WITHOUT a trailing blank line; callers add
|
|
233
|
+
spacing. Shared by `_render_blocks_table` and `_render_active_block_box`.
|
|
234
|
+
"""
|
|
235
|
+
def _bold(s: str) -> str:
|
|
236
|
+
return _style_ansi(s, "1", color)
|
|
237
|
+
title_padded = f" {title} "
|
|
238
|
+
tw = len(title_padded)
|
|
239
|
+
dash = "─" if unicode_ok else "-"
|
|
240
|
+
vb = "│" if unicode_ok else "|"
|
|
241
|
+
if unicode_ok:
|
|
242
|
+
top = f" ╭{dash * tw}╮"
|
|
243
|
+
bot = f" ╰{dash * tw}╯"
|
|
244
|
+
else:
|
|
245
|
+
top = f" +{'-' * tw}+"
|
|
246
|
+
bot = f" +{'-' * tw}+"
|
|
247
|
+
return "\n".join([
|
|
248
|
+
top,
|
|
249
|
+
f" {vb}" + " " * tw + vb,
|
|
250
|
+
f" {vb}" + _bold(title_padded) + vb,
|
|
251
|
+
f" {vb}" + " " * tw + vb,
|
|
252
|
+
bot,
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _fmt_block_time_local(ts: dt.datetime, tz: "ZoneInfo | None") -> str:
|
|
257
|
+
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style."""
|
|
258
|
+
local = ts.astimezone(tz)
|
|
259
|
+
hour_12 = local.hour % 12 or 12
|
|
260
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
261
|
+
return (
|
|
262
|
+
f"{local.year}-{local.month:02d}-{local.day:02d}, "
|
|
263
|
+
f"{hour_12}:{local.minute:02d}:{local.second:02d} {ampm}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fmt_block_duration_hm(total_seconds: float) -> str:
|
|
268
|
+
total_minutes = int(total_seconds / 60)
|
|
269
|
+
return f"{total_minutes // 60}h {total_minutes % 60:02d}m"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _render_active_block_box(
|
|
273
|
+
block: "Block",
|
|
274
|
+
*,
|
|
275
|
+
now: dt.datetime,
|
|
276
|
+
tz: "ZoneInfo | None",
|
|
277
|
+
token_limit_explicit: int | None,
|
|
278
|
+
color: bool,
|
|
279
|
+
unicode_ok: bool,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""ccusage `-a` 'Current Session Block Status' box (#86 Session F).
|
|
282
|
+
|
|
283
|
+
Token Limit Status sub-block appears only when `-t` was explicitly passed
|
|
284
|
+
(`token_limit_explicit` not None and > 0). Heuristic-anchored active blocks
|
|
285
|
+
get a `~`-prefixed start + approximate-start legend (Codex F2).
|
|
286
|
+
"""
|
|
287
|
+
def _b(s: str) -> str:
|
|
288
|
+
return _style_ansi(s, "1", color)
|
|
289
|
+
|
|
290
|
+
lines: list[str] = [
|
|
291
|
+
_render_title_banner("Current Session Block Status",
|
|
292
|
+
unicode_ok=unicode_ok, color=color),
|
|
293
|
+
]
|
|
294
|
+
started = _fmt_block_time_local(block.start_time, tz)
|
|
295
|
+
approx = block.anchor == "heuristic"
|
|
296
|
+
if approx:
|
|
297
|
+
started = f"~{started}"
|
|
298
|
+
elapsed = max((now - block.start_time).total_seconds(), 0)
|
|
299
|
+
remaining = max((block.end_time - now).total_seconds(), 0)
|
|
300
|
+
lines.append(f"Block Started: {started} "
|
|
301
|
+
f"({_fmt_block_duration_hm(elapsed)} ago)")
|
|
302
|
+
lines.append(f"Time Remaining: {_fmt_block_duration_hm(remaining)}")
|
|
303
|
+
if approx:
|
|
304
|
+
# Keep this wording in sync with the table footer legend below.
|
|
305
|
+
lines.append("~ = approximate start "
|
|
306
|
+
"(no recorded Anthropic reset for this window)")
|
|
307
|
+
|
|
308
|
+
lines += ["", _b("Current Usage:"),
|
|
309
|
+
f" Input Tokens: {_fmt_num(block.input_tokens)}",
|
|
310
|
+
f" Output Tokens: {_fmt_num(block.output_tokens)}",
|
|
311
|
+
f" Total Cost: ${block.cost_usd:.2f}"]
|
|
312
|
+
|
|
313
|
+
if block.burn_rate:
|
|
314
|
+
lines += ["", _b("Burn Rate:"),
|
|
315
|
+
f" Tokens/minute: "
|
|
316
|
+
f"{_fmt_num(int(block.burn_rate['tokensPerMinute']))}",
|
|
317
|
+
f" Cost/hour: ${block.burn_rate['costPerHour']:.2f}"]
|
|
318
|
+
|
|
319
|
+
if block.projection:
|
|
320
|
+
proj = block.projection
|
|
321
|
+
lines += ["", _b("Projected Usage (if current rate continues):"),
|
|
322
|
+
f" Total Tokens: {_fmt_num(proj['totalTokens'])}",
|
|
323
|
+
f" Total Cost: ${proj['totalCost']:.2f}"]
|
|
324
|
+
if token_limit_explicit is not None and token_limit_explicit > 0:
|
|
325
|
+
limit = token_limit_explicit
|
|
326
|
+
current = block.total_tokens
|
|
327
|
+
remaining_tokens = max(limit - current, 0)
|
|
328
|
+
pct_used = (proj["totalTokens"] / limit) * 100.0
|
|
329
|
+
cur_pct = (current / limit) * 100.0
|
|
330
|
+
# Keep the EXCEEDS/WARNING/OK thresholds (>100 / >80) in sync with
|
|
331
|
+
# the JSON status ladder in _lib_blocks._blocks_to_json.
|
|
332
|
+
if pct_used > 100:
|
|
333
|
+
status = _style_ansi("EXCEEDS LIMIT", "31", color)
|
|
334
|
+
elif pct_used > 80:
|
|
335
|
+
status = _style_ansi("WARNING", "33", color)
|
|
336
|
+
else:
|
|
337
|
+
status = _style_ansi("OK", "32", color)
|
|
338
|
+
lines += ["", _b("Token Limit Status:"),
|
|
339
|
+
f" Limit: {_fmt_num(limit)} tokens",
|
|
340
|
+
f" Current Usage: {_fmt_num(current)} ({cur_pct:.1f}%)",
|
|
341
|
+
f" Remaining: {_fmt_num(remaining_tokens)} tokens",
|
|
342
|
+
f" Projected Usage: {pct_used:.1f}% {status}"]
|
|
343
|
+
return "\n".join(lines)
|
|
344
|
+
|
|
345
|
+
|
|
228
346
|
def _render_blocks_table(
|
|
229
347
|
blocks: list[Block],
|
|
230
348
|
breakdown: bool = False,
|
|
@@ -232,6 +350,7 @@ def _render_blocks_table(
|
|
|
232
350
|
now: dt.datetime | None = None,
|
|
233
351
|
tz: "ZoneInfo | None" = None,
|
|
234
352
|
compact: bool = False,
|
|
353
|
+
token_limit: int | None = None,
|
|
235
354
|
) -> str:
|
|
236
355
|
"""Render blocks as a ccusage-style ANSI table with box-drawing borders.
|
|
237
356
|
|
|
@@ -268,9 +387,6 @@ def _render_blocks_table(
|
|
|
268
387
|
def _cyan(s: str) -> str:
|
|
269
388
|
return _style_ansi(s, "36", color)
|
|
270
389
|
|
|
271
|
-
def _bold(s: str) -> str:
|
|
272
|
-
return _style_ansi(s, "1", color)
|
|
273
|
-
|
|
274
390
|
def _green(s: str) -> str:
|
|
275
391
|
return _style_ansi(s, "32", color)
|
|
276
392
|
|
|
@@ -283,21 +399,10 @@ def _render_blocks_table(
|
|
|
283
399
|
# ── time formatting ─────────────────────────────────────────────────
|
|
284
400
|
|
|
285
401
|
def _fmt_time_local(ts: dt.datetime) -> str:
|
|
286
|
-
|
|
287
|
-
hour_12 = local.hour % 12
|
|
288
|
-
if hour_12 == 0:
|
|
289
|
-
hour_12 = 12
|
|
290
|
-
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
291
|
-
return (
|
|
292
|
-
f"{local.year}-{local.month:02d}-{local.day:02d}, "
|
|
293
|
-
f"{hour_12}:{local.minute:02d}:{local.second:02d} {ampm}"
|
|
294
|
-
)
|
|
402
|
+
return _fmt_block_time_local(ts, tz)
|
|
295
403
|
|
|
296
404
|
def _fmt_duration_hm(total_seconds: float) -> str:
|
|
297
|
-
|
|
298
|
-
h = total_minutes // 60
|
|
299
|
-
m = total_minutes % 60
|
|
300
|
-
return f"{h}h {m:02d}m"
|
|
405
|
+
return _fmt_block_duration_hm(total_seconds)
|
|
301
406
|
|
|
302
407
|
def _fmt_gap_duration(total_seconds: float) -> str:
|
|
303
408
|
total_minutes = int(total_seconds / 60)
|
|
@@ -307,19 +412,23 @@ def _render_blocks_table(
|
|
|
307
412
|
return f"{h}h {m:02d}m gap" if m else f"{h}h gap"
|
|
308
413
|
|
|
309
414
|
# ── determine if % column is needed ─────────────────────────────────
|
|
310
|
-
max_completed_tokens =
|
|
311
|
-
for b in blocks:
|
|
312
|
-
if not b.is_gap and not b.is_active and b.total_tokens > 0:
|
|
313
|
-
if b.total_tokens > max_completed_tokens:
|
|
314
|
-
max_completed_tokens = b.total_tokens
|
|
315
|
-
token_limit = 0
|
|
415
|
+
max_completed_tokens = _lib_blocks._max_completed_block_tokens(blocks)
|
|
316
416
|
active_block: Block | None = None
|
|
317
417
|
for b in blocks:
|
|
318
418
|
if b.is_active and not b.is_gap:
|
|
319
419
|
active_block = b
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
420
|
+
# ``token_limit`` param (#86 Session F): when caller passes an explicit
|
|
421
|
+
# limit, honor it directly (forces the %/REMAINING/PROJECTED surface even
|
|
422
|
+
# with no completed history, suppressed only when <= 0). When None, fall
|
|
423
|
+
# back to the internal auto-max recompute so the renderer stays
|
|
424
|
+
# standalone-callable and the default ``cmd_blocks`` path (which passes the
|
|
425
|
+
# resolved auto-max) is byte-identical.
|
|
426
|
+
if token_limit is not None:
|
|
427
|
+
show_pct = token_limit > 0
|
|
428
|
+
eff_limit = token_limit if token_limit > 0 else 0
|
|
429
|
+
else:
|
|
430
|
+
show_pct = max_completed_tokens > 0
|
|
431
|
+
eff_limit = max_completed_tokens if show_pct else 0
|
|
323
432
|
|
|
324
433
|
# ── column layout ───────────────────────────────────────────────────
|
|
325
434
|
headers = ["Block Start", "Duration/\u2026", "Models", "Tokens"]
|
|
@@ -405,8 +514,8 @@ def _render_blocks_table(
|
|
|
405
514
|
short_models = [""]
|
|
406
515
|
|
|
407
516
|
pct_str = ""
|
|
408
|
-
if show_pct and
|
|
409
|
-
pct_val = (block.total_tokens /
|
|
517
|
+
if show_pct and eff_limit > 0:
|
|
518
|
+
pct_val = (block.total_tokens / eff_limit) * 100.0
|
|
410
519
|
pct_str = f"{pct_val:.1f}%"
|
|
411
520
|
|
|
412
521
|
tokens_str = _fmt_num(block.total_tokens)
|
|
@@ -436,11 +545,11 @@ def _render_blocks_table(
|
|
|
436
545
|
|
|
437
546
|
# Footer rows (REMAINING, PROJECTED)
|
|
438
547
|
footer_rows: list[tuple[list[list[str]], str]] = []
|
|
439
|
-
if show_pct and
|
|
548
|
+
if show_pct and eff_limit > 0:
|
|
440
549
|
active_tokens = active_block.total_tokens if active_block else 0
|
|
441
|
-
remaining_tokens = max(
|
|
442
|
-
remaining_pct = (remaining_tokens /
|
|
443
|
-
rem_label = f"(assuming {_fmt_num(
|
|
550
|
+
remaining_tokens = max(eff_limit - active_tokens, 0)
|
|
551
|
+
remaining_pct = (remaining_tokens / eff_limit) * 100.0
|
|
552
|
+
rem_label = f"(assuming {_fmt_num(eff_limit)} token limit)"
|
|
444
553
|
|
|
445
554
|
rem_cells = _empty_cells()
|
|
446
555
|
rem_cells[0] = rem_label
|
|
@@ -456,7 +565,7 @@ def _render_blocks_table(
|
|
|
456
565
|
proj = active_block.projection
|
|
457
566
|
proj_tokens = proj.get("totalTokens", 0)
|
|
458
567
|
proj_pct = (
|
|
459
|
-
(proj_tokens /
|
|
568
|
+
(proj_tokens / eff_limit) * 100.0 if eff_limit > 0 else 0
|
|
460
569
|
)
|
|
461
570
|
proj_cost = proj.get("totalCost", 0.0)
|
|
462
571
|
|
|
@@ -600,23 +709,8 @@ def _render_blocks_table(
|
|
|
600
709
|
|
|
601
710
|
# Title banner
|
|
602
711
|
title = "Claude Code Token Usage Report - Session Blocks"
|
|
603
|
-
title_padded = f" {title} "
|
|
604
|
-
tw = len(title_padded)
|
|
605
|
-
dash = "\u2500" if unicode_ok else "-"
|
|
606
|
-
vb = "\u2502" if unicode_ok else "|"
|
|
607
|
-
if unicode_ok:
|
|
608
|
-
banner_top = f" \u256d{dash * tw}\u256e"
|
|
609
|
-
banner_bot = f" \u2570{dash * tw}\u256f"
|
|
610
|
-
else:
|
|
611
|
-
banner_top = f" +{'-' * tw}+"
|
|
612
|
-
banner_bot = f" +{'-' * tw}+"
|
|
613
|
-
|
|
614
712
|
lines: list[str] = []
|
|
615
|
-
lines.append(
|
|
616
|
-
lines.append(f" {vb}" + " " * tw + vb)
|
|
617
|
-
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
618
|
-
lines.append(f" {vb}" + " " * tw + vb)
|
|
619
|
-
lines.append(banner_bot)
|
|
713
|
+
lines.append(_render_title_banner(title, unicode_ok=unicode_ok, color=color))
|
|
620
714
|
lines.append("")
|
|
621
715
|
|
|
622
716
|
# Header
|
|
@@ -979,6 +1073,57 @@ def _codex_bucket_to_json(
|
|
|
979
1073
|
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
980
1074
|
|
|
981
1075
|
|
|
1076
|
+
def _codex_root_short_labels(roots: list[str]) -> list[str]:
|
|
1077
|
+
"""Given >=2 distinct root paths, return a short distinguishing label per
|
|
1078
|
+
root: the first path segment after their longest common ancestor — e.g.
|
|
1079
|
+
{".../rootA/.codex", ".../rootB/.codex"} -> ["rootA", "rootB"]. If a single
|
|
1080
|
+
segment isn't unique, fall back to the full post-ancestor tail, then to the
|
|
1081
|
+
whole path, so the returned labels are always pairwise distinct.
|
|
1082
|
+
|
|
1083
|
+
Order-preserving: labels[i] corresponds to roots[i].
|
|
1084
|
+
"""
|
|
1085
|
+
parts = [pathlib.PurePosixPath(r).parts for r in roots]
|
|
1086
|
+
common = 0
|
|
1087
|
+
for seg in zip(*parts):
|
|
1088
|
+
if len(set(seg)) == 1:
|
|
1089
|
+
common += 1
|
|
1090
|
+
else:
|
|
1091
|
+
break
|
|
1092
|
+
singles = [pr[common] if common < len(pr) else (pr[-1] if pr else "")
|
|
1093
|
+
for pr in parts]
|
|
1094
|
+
if len(set(singles)) == len(singles):
|
|
1095
|
+
return singles
|
|
1096
|
+
tails = ["/".join(pr[common:]) or (pr[-1] if pr else "") for pr in parts]
|
|
1097
|
+
if len(set(tails)) == len(tails):
|
|
1098
|
+
return tails
|
|
1099
|
+
return list(roots)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _codex_session_disambiguate(sessions: list[CodexSessionUsage]) -> dict[int, str]:
|
|
1103
|
+
"""Return ``{row_index: " (label)"}`` for codex-session rows whose
|
|
1104
|
+
``session_id_path`` collides with another row's — a genuine
|
|
1105
|
+
cross-$CODEX_HOME-root collision (issue #110). Non-colliding rows are absent
|
|
1106
|
+
from the dict; callers render them byte-identically (single-root data never
|
|
1107
|
+
collides, so this returns ``{}`` and output is unchanged).
|
|
1108
|
+
|
|
1109
|
+
``label`` is a short distinguishing segment of the matched $CODEX_HOME root
|
|
1110
|
+
(see ``_codex_root_short_labels``). Mirrors ``_project_disambiguate_labels``.
|
|
1111
|
+
"""
|
|
1112
|
+
path_counts: dict[str, int] = {}
|
|
1113
|
+
for s in sessions:
|
|
1114
|
+
path_counts[s.session_id_path] = path_counts.get(s.session_id_path, 0) + 1
|
|
1115
|
+
groups: dict[str, list[int]] = {}
|
|
1116
|
+
for idx, s in enumerate(sessions):
|
|
1117
|
+
if path_counts[s.session_id_path] > 1:
|
|
1118
|
+
groups.setdefault(s.session_id_path, []).append(idx)
|
|
1119
|
+
out: dict[int, str] = {}
|
|
1120
|
+
for idxs in groups.values():
|
|
1121
|
+
labels = _codex_root_short_labels([sessions[i].codex_root for i in idxs])
|
|
1122
|
+
for i, label in zip(idxs, labels):
|
|
1123
|
+
out[i] = f" ({label})"
|
|
1124
|
+
return out
|
|
1125
|
+
|
|
1126
|
+
|
|
982
1127
|
def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
983
1128
|
"""Serialize Codex session aggregates to JSON matching upstream exactly.
|
|
984
1129
|
|
|
@@ -990,9 +1135,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
990
1135
|
session_list: list[dict[str, Any]] = []
|
|
991
1136
|
tot_input = tot_cached = tot_output = tot_reasoning = tot_tokens = 0
|
|
992
1137
|
tot_cost = 0.0
|
|
993
|
-
|
|
1138
|
+
# Issue #110: only when two cross-root sessions share `sessionId` (the
|
|
1139
|
+
# relative path) does this map a colliding row to a label; otherwise empty,
|
|
1140
|
+
# so the per-session shape stays upstream-byte-identical (no `codexRoot`).
|
|
1141
|
+
disambig = _codex_session_disambiguate(sessions)
|
|
1142
|
+
for idx, s in enumerate(sessions):
|
|
994
1143
|
session_total = s.input_tokens + s.output_tokens
|
|
995
|
-
|
|
1144
|
+
entry = {
|
|
996
1145
|
"sessionId": s.session_id_path,
|
|
997
1146
|
"lastActivity": _codex_last_activity_iso(s.last_activity),
|
|
998
1147
|
"sessionFile": s.session_file,
|
|
@@ -1004,7 +1153,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1004
1153
|
"totalTokens": session_total,
|
|
1005
1154
|
"costUSD": s.cost_usd,
|
|
1006
1155
|
"models": _codex_models_dict(s.model_breakdowns),
|
|
1007
|
-
}
|
|
1156
|
+
}
|
|
1157
|
+
if idx in disambig:
|
|
1158
|
+
# Additive disambiguator — `sessionId` keeps its upstream-compatible
|
|
1159
|
+
# relative-path value; consumers key off `codexRoot` to tell the
|
|
1160
|
+
# (correctly separate) colliding rows apart.
|
|
1161
|
+
entry["codexRoot"] = s.codex_root
|
|
1162
|
+
session_list.append(entry)
|
|
1008
1163
|
tot_input += s.input_tokens
|
|
1009
1164
|
tot_cached += s.cached_input_tokens
|
|
1010
1165
|
tot_output += s.output_tokens
|
|
@@ -2095,14 +2250,18 @@ def _render_codex_session_table(
|
|
|
2095
2250
|
ROW_DATA, ROW_FOOTER = "data", "footer"
|
|
2096
2251
|
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
2097
2252
|
|
|
2098
|
-
|
|
2253
|
+
# Issue #110: suffix the Session cell with the matched $CODEX_HOME root
|
|
2254
|
+
# ONLY for cross-root collisions (two rows sharing session_id_path); empty
|
|
2255
|
+
# for single-root data, so the table stays byte-identical.
|
|
2256
|
+
disambig = _codex_session_disambiguate(sessions)
|
|
2257
|
+
for idx, s in enumerate(sessions):
|
|
2099
2258
|
models_text = "\n".join(f"- {m}" for m in s.models) if s.models else ""
|
|
2100
2259
|
non_cached = max(0, s.input_tokens - s.cached_input_tokens)
|
|
2101
2260
|
session_total = s.input_tokens + s.output_tokens
|
|
2102
2261
|
data_cells = [
|
|
2103
2262
|
(_date_cell(s.last_activity), None),
|
|
2104
2263
|
(s.directory, None),
|
|
2105
|
-
(_session_cell(s.session_id), None),
|
|
2264
|
+
(_session_cell(s.session_id) + disambig.get(idx, ""), None),
|
|
2106
2265
|
(models_text, None),
|
|
2107
2266
|
(_fmt_num(non_cached), None),
|
|
2108
2267
|
(_fmt_num(s.output_tokens), None),
|
package/bin/cctally
CHANGED
|
@@ -349,6 +349,8 @@ _group_entries_into_blocks = _lib_blocks._group_entries_into_blocks
|
|
|
349
349
|
_aggregate_block = _lib_blocks._aggregate_block
|
|
350
350
|
_build_activity_block = _lib_blocks._build_activity_block
|
|
351
351
|
_blocks_to_json = _lib_blocks._blocks_to_json
|
|
352
|
+
_max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
|
|
353
|
+
_parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
|
|
352
354
|
|
|
353
355
|
_lib_changelog = _load_sibling("_lib_changelog")
|
|
354
356
|
# Backward-compat re-export: callers and tests reach the helper through
|
|
@@ -409,6 +411,7 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
|
409
411
|
_lib_render = _load_sibling("_lib_render")
|
|
410
412
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
411
413
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
414
|
+
_render_active_block_box = _lib_render._render_active_block_box
|
|
412
415
|
_daily_row_dict = _lib_render._daily_row_dict
|
|
413
416
|
_bucket_totals_dict = _lib_render._bucket_totals_dict
|
|
414
417
|
_bucket_to_json = _lib_render._bucket_to_json
|
|
@@ -618,7 +621,6 @@ cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
|
618
621
|
_cctally_cache = _load_sibling("_cctally_cache")
|
|
619
622
|
ProjectKey = _cctally_cache.ProjectKey
|
|
620
623
|
_resolve_project_key = _cctally_cache._resolve_project_key
|
|
621
|
-
_get_codex_sessions_dir = _cctally_cache._get_codex_sessions_dir
|
|
622
624
|
_discover_codex_session_files = _cctally_cache._discover_codex_session_files
|
|
623
625
|
IngestStats = _cctally_cache.IngestStats
|
|
624
626
|
_progress_stderr = _cctally_cache._progress_stderr
|
|
@@ -875,6 +877,84 @@ LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
|
|
|
875
877
|
|
|
876
878
|
CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
|
|
877
879
|
|
|
880
|
+
|
|
881
|
+
def _codex_home_roots() -> list[pathlib.Path]:
|
|
882
|
+
"""ALL raw $CODEX_HOME entries (comma-split), else [~/.codex].
|
|
883
|
+
|
|
884
|
+
These are the entries upstream calls "CODEX_HOME roots" — the full
|
|
885
|
+
comma-split list, BEFORE the sessions/-subdir rule decides home-vs-direct.
|
|
886
|
+
The config reader (_detect_codex_fast_service_tier) reads <entry>/config.toml
|
|
887
|
+
for EVERY entry returned here, including ones that turn out to be direct
|
|
888
|
+
JSONL dirs; only _codex_session_roots() applies the sessions/-subdir
|
|
889
|
+
narrowing. Blank/whitespace entries are dropped; each is expanduser'd
|
|
890
|
+
(literal '~'; the shell already expands $VAR before we see the value)
|
|
891
|
+
then made ABSOLUTE via .absolute() — a relative $CODEX_HOME (e.g.
|
|
892
|
+
`./codexA`) would otherwise glob and store relative source_paths, which
|
|
893
|
+
the cache-prune step cannot distinguish from synthetic relative-path
|
|
894
|
+
fixture rows (issue #108; see the prune guard in _cctally_cache.py).
|
|
895
|
+
Canonicalizing here makes every REAL ingested source_path absolute, so
|
|
896
|
+
the prune's `isabs` fixture carve-out is correct by construction.
|
|
897
|
+
Order is preserved (first-match-wins downstream).
|
|
898
|
+
"""
|
|
899
|
+
raw = os.environ.get("CODEX_HOME", "").strip()
|
|
900
|
+
if not raw:
|
|
901
|
+
return [pathlib.Path.home() / ".codex"]
|
|
902
|
+
roots: list[pathlib.Path] = []
|
|
903
|
+
saw_part = False
|
|
904
|
+
for part in raw.split(","):
|
|
905
|
+
part = part.strip()
|
|
906
|
+
if not part:
|
|
907
|
+
continue
|
|
908
|
+
saw_part = True
|
|
909
|
+
try:
|
|
910
|
+
# expanduser() raises RuntimeError on a malformed `~user` entry
|
|
911
|
+
# (e.g. `~nonexistentuser/x` — the user doesn't exist). Drop the
|
|
912
|
+
# bad entry rather than aborting the whole command, so valid
|
|
913
|
+
# roots beside it survive. .absolute() canonicalizes a relative
|
|
914
|
+
# entry (e.g. `./codexA`) against cwd so real ingested source_paths
|
|
915
|
+
# are always absolute (issue #108 — keeps the cache-prune correct).
|
|
916
|
+
roots.append(pathlib.Path(part).expanduser().absolute())
|
|
917
|
+
except (RuntimeError, OSError, ValueError):
|
|
918
|
+
continue
|
|
919
|
+
# Fall back to the default home ONLY when the variable is unset or carries
|
|
920
|
+
# no non-blank entry at all. An explicit-but-all-invalid value (e.g. a lone
|
|
921
|
+
# `~baduser` that expanduser() drops) yields [] — respect the override and
|
|
922
|
+
# read nothing rather than silently reading the default account (issue
|
|
923
|
+
# #108). A plain dead path like `/typo` already reaches [] via the is_dir()
|
|
924
|
+
# filter in _codex_session_roots(); this aligns the `~user` flavor with it.
|
|
925
|
+
if not saw_part:
|
|
926
|
+
return [pathlib.Path.home() / ".codex"]
|
|
927
|
+
return roots
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _codex_session_roots() -> list[pathlib.Path]:
|
|
931
|
+
"""Directories to walk for *.jsonl, applying the sessions/-subdir rule.
|
|
932
|
+
|
|
933
|
+
For each home root r (in $CODEX_HOME order):
|
|
934
|
+
(r / "sessions").is_dir() -> r / "sessions" (Codex home)
|
|
935
|
+
elif r.is_dir() -> r (direct JSONL dir)
|
|
936
|
+
else -> skipped
|
|
937
|
+
Exact-duplicate roots are de-duped (first occurrence kept). File-level
|
|
938
|
+
de-dup for overlapping/prefix roots happens in the discovery walkers
|
|
939
|
+
(set of absolute paths); this function only collapses identical roots.
|
|
940
|
+
"""
|
|
941
|
+
out: list[pathlib.Path] = []
|
|
942
|
+
seen: set[pathlib.Path] = set()
|
|
943
|
+
for r in _codex_home_roots():
|
|
944
|
+
sess = r / "sessions"
|
|
945
|
+
if sess.is_dir():
|
|
946
|
+
cand = sess
|
|
947
|
+
elif r.is_dir():
|
|
948
|
+
cand = r
|
|
949
|
+
else:
|
|
950
|
+
continue
|
|
951
|
+
if cand in seen:
|
|
952
|
+
continue
|
|
953
|
+
seen.add(cand)
|
|
954
|
+
out.append(cand)
|
|
955
|
+
return out
|
|
956
|
+
|
|
957
|
+
|
|
878
958
|
# Note: `_cctally_db` reads its four path constants
|
|
879
959
|
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
|
|
880
960
|
# `_cctally_core.X` at call time — the canonical sibling pattern after
|
|
@@ -4223,6 +4303,15 @@ def _load_recorded_five_hour_windows(
|
|
|
4223
4303
|
|
|
4224
4304
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
4225
4305
|
"""Show usage report grouped by 5-hour session blocks."""
|
|
4306
|
+
# -n/--session-length guard (#86 Session F). The flag is a documented
|
|
4307
|
+
# no-op (cctally blocks anchor to Anthropic's real 5h resets and are not
|
|
4308
|
+
# re-sizable), but a non-positive value still errors for drop-in fidelity
|
|
4309
|
+
# with ccusage's "Session length must be a positive number". Runs first,
|
|
4310
|
+
# before any data load — matches ccusage's command-flow ordering.
|
|
4311
|
+
if getattr(args, "session_length", 5.0) <= 0:
|
|
4312
|
+
eprint("blocks: session length must be a positive number")
|
|
4313
|
+
return 1
|
|
4314
|
+
|
|
4226
4315
|
config = _load_claude_config_for_args(args)
|
|
4227
4316
|
_bridge_z_into_tz(args, config)
|
|
4228
4317
|
tz = resolve_display_tz(args, config)
|
|
@@ -4322,16 +4411,60 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
4322
4411
|
# 20:50→01:50 with $45 cost vs the real $128).
|
|
4323
4412
|
_maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
|
|
4324
4413
|
|
|
4414
|
+
# ── Session F (#86): resolve token limit, then filter ────────────────
|
|
4415
|
+
# Auto-max baseline over ALL blocks (before --recent/--active filtering),
|
|
4416
|
+
# matching ccusage's maxTokensFromAll.
|
|
4417
|
+
max_completed = _max_completed_block_tokens(blocks)
|
|
4418
|
+
token_limit = _parse_blocks_token_limit(
|
|
4419
|
+
getattr(args, "token_limit", None), max_completed
|
|
4420
|
+
)
|
|
4421
|
+
# ``token_limit_explicit`` is the resolved limit ONLY when -t was passed
|
|
4422
|
+
# (any value incl. "max"); the implicit default leaves it None so the
|
|
4423
|
+
# box's Token Limit Status sub-block + the JSON tokenLimitStatus key are
|
|
4424
|
+
# omitted (ccusage `if (tokenLimit != null)` gate).
|
|
4425
|
+
token_limit_explicit = (
|
|
4426
|
+
token_limit if getattr(args, "token_limit", None) is not None else None
|
|
4427
|
+
)
|
|
4428
|
+
auto_max = getattr(args, "token_limit", None) in (None, "", "max")
|
|
4429
|
+
if auto_max and token_limit and not args.json:
|
|
4430
|
+
# ccusage parity: logger.info → stdout (Codex F1). Suppressed under
|
|
4431
|
+
# --json (ccusage sets logger.level=0), so --json goldens stay stable.
|
|
4432
|
+
print(f"Using max tokens from previous sessions: {_fmt_num(token_limit)}")
|
|
4433
|
+
|
|
4434
|
+
if getattr(args, "recent", False):
|
|
4435
|
+
cutoff = now_utc - dt.timedelta(days=3)
|
|
4436
|
+
blocks = [b for b in blocks if b.start_time >= cutoff or b.is_active]
|
|
4437
|
+
|
|
4438
|
+
if getattr(args, "active", False):
|
|
4439
|
+
blocks = [b for b in blocks if b.is_active and not b.is_gap]
|
|
4440
|
+
if not blocks:
|
|
4441
|
+
if args.json:
|
|
4442
|
+
print('{\n "blocks": [],\n "message": "No active block"\n}')
|
|
4443
|
+
else:
|
|
4444
|
+
print("No active session block found.")
|
|
4445
|
+
return 0
|
|
4446
|
+
|
|
4325
4447
|
if args.json:
|
|
4326
|
-
print(_blocks_to_json(blocks))
|
|
4448
|
+
print(_blocks_to_json(blocks, token_limit_status_limit=token_limit_explicit))
|
|
4449
|
+
return 0
|
|
4450
|
+
|
|
4451
|
+
if getattr(args, "active", False) and len(blocks) == 1:
|
|
4452
|
+
print(_render_active_block_box(
|
|
4453
|
+
blocks[0], now=now_utc, tz=tz,
|
|
4454
|
+
token_limit_explicit=token_limit_explicit,
|
|
4455
|
+
color=_supports_color_stdout(), unicode_ok=_supports_unicode_stdout(),
|
|
4456
|
+
))
|
|
4327
4457
|
return 0
|
|
4328
4458
|
|
|
4329
4459
|
# Table output. Session A (spec §7.6.1; Review-A P2-B): thread
|
|
4330
4460
|
# --compact through so the renderer's scale-down branch fires
|
|
4331
|
-
# regardless of terminal width when the flag is set.
|
|
4461
|
+
# regardless of terminal width when the flag is set. Session F: thread
|
|
4462
|
+
# the resolved token_limit so an explicit -t keys the %/REMAINING/
|
|
4463
|
+
# PROJECTED surface (the default path passes the same auto-max the
|
|
4464
|
+
# renderer computed internally, so it stays byte-identical).
|
|
4332
4465
|
print(_render_blocks_table(
|
|
4333
4466
|
blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
|
|
4334
|
-
compact=getattr(args, "compact", False),
|
|
4467
|
+
compact=getattr(args, "compact", False), token_limit=token_limit,
|
|
4335
4468
|
))
|
|
4336
4469
|
return 0
|
|
4337
4470
|
|
|
@@ -4971,25 +5104,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
4971
5104
|
|
|
4972
5105
|
|
|
4973
5106
|
def _detect_codex_fast_service_tier() -> bool:
|
|
4974
|
-
"""True iff
|
|
4975
|
-
|
|
4976
|
-
Reads
|
|
4977
|
-
|
|
4978
|
-
|
|
5107
|
+
"""True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
|
|
5108
|
+
|
|
5109
|
+
Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
|
|
5110
|
+
separated $CODEX_HOME, else ~/.codex) — including direct-JSONL entries,
|
|
5111
|
+
which usually have no config.toml (read → absent → skipped) but DO count
|
|
5112
|
+
if one is present. Returns on the first root that requests it (any-root
|
|
5113
|
+
semantics, matching upstream ccusage). Tolerates absent/unreadable config
|
|
5114
|
+
(→ that root contributes nothing).
|
|
4979
5115
|
"""
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
5116
|
+
for root in _codex_home_roots():
|
|
5117
|
+
cfg = root / "config.toml"
|
|
5118
|
+
try:
|
|
5119
|
+
content = cfg.read_text(encoding="utf-8", errors="replace")
|
|
5120
|
+
except OSError:
|
|
5121
|
+
continue
|
|
5122
|
+
if _codex_config_requests_fast_service_tier(content):
|
|
5123
|
+
return True
|
|
5124
|
+
return False
|
|
4986
5125
|
|
|
4987
5126
|
|
|
4988
5127
|
def _resolve_codex_speed(requested: str) -> str:
|
|
4989
5128
|
"""Resolve a ``--speed`` value to an effective tier.
|
|
4990
5129
|
|
|
4991
|
-
``auto`` → ``fast`` iff
|
|
4992
|
-
``standard``. ``fast``/``standard`` pass through
|
|
5130
|
+
``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
|
|
5131
|
+
requests it, else ``standard``. ``fast``/``standard`` pass through
|
|
5132
|
+
unchanged.
|
|
4993
5133
|
"""
|
|
4994
5134
|
if requested == "auto":
|
|
4995
5135
|
return "fast" if _detect_codex_fast_service_tier() else "standard"
|
|
@@ -9830,11 +9970,16 @@ def doctor_gather_state(
|
|
|
9830
9970
|
except Exception:
|
|
9831
9971
|
pass
|
|
9832
9972
|
|
|
9973
|
+
# Issue #109: probe every $CODEX_HOME session root (not the single
|
|
9974
|
+
# hardcoded ~/.codex/sessions), matching the multi-root ingestion path
|
|
9975
|
+
# from #108. _codex_session_roots() already applies the sessions/-subdir
|
|
9976
|
+
# rule and filters to existing dirs, so a bare glob per root suffices.
|
|
9833
9977
|
codex_jsonl_present = False
|
|
9834
9978
|
try:
|
|
9835
|
-
codex_dir
|
|
9836
|
-
|
|
9837
|
-
|
|
9979
|
+
for codex_dir in _codex_session_roots():
|
|
9980
|
+
if next(codex_dir.glob("**/*.jsonl"), None) is not None:
|
|
9981
|
+
codex_jsonl_present = True
|
|
9982
|
+
break
|
|
9838
9983
|
except Exception:
|
|
9839
9984
|
pass
|
|
9840
9985
|
|
|
@@ -10601,6 +10746,29 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
|
|
|
10601
10746
|
help="Display timezone: local, utc, or IANA name. "
|
|
10602
10747
|
"Overrides config display.tz for this call.",
|
|
10603
10748
|
)
|
|
10749
|
+
p.add_argument(
|
|
10750
|
+
"-a", "--active", action="store_true",
|
|
10751
|
+
help="Show only the active block, with burn-rate + projection "
|
|
10752
|
+
"(ccusage drop-in).",
|
|
10753
|
+
)
|
|
10754
|
+
p.add_argument(
|
|
10755
|
+
"-r", "--recent", action="store_true",
|
|
10756
|
+
help="Show only blocks from the last 3 days (plus the active block).",
|
|
10757
|
+
)
|
|
10758
|
+
p.add_argument(
|
|
10759
|
+
"-t", "--token-limit", dest="token_limit", default=None,
|
|
10760
|
+
metavar="N|max",
|
|
10761
|
+
help="Token limit for the quota %% column / projection warnings. "
|
|
10762
|
+
"An integer, or 'max' (default) to derive from the largest "
|
|
10763
|
+
"completed block.",
|
|
10764
|
+
)
|
|
10765
|
+
p.add_argument(
|
|
10766
|
+
"-n", "--session-length", dest="session_length", type=float,
|
|
10767
|
+
default=5.0, metavar="N",
|
|
10768
|
+
help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
|
|
10769
|
+
"follow Anthropic's real 5-hour resets and are not re-sizable. "
|
|
10770
|
+
"A value <= 0 is rejected.",
|
|
10771
|
+
)
|
|
10604
10772
|
_add_ccusage_alias_args(p, ansi_emit=False)
|
|
10605
10773
|
_add_mode_arg(p)
|
|
10606
10774
|
p.set_defaults(func=cmd_blocks)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|