cctally 1.17.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 CHANGED
@@ -5,6 +5,16 @@ 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
+
13
+ ## [1.18.0] - 2026-05-27
14
+
15
+ ### Added
16
+ - **Project-axis flags on `cctally daily` (and `cctally claude daily`): `-i/--instances`, `-p/--project`, and `--project-aliases`** — a drop-in for `ccusage daily`. `-i/--instances` groups the daily report by project (git-root), rendering a `Project: <label>` section per project with one global Total (`--json` becomes `{projects: {...}, totals}`); `-p/--project PATTERN` filters to matching projects by case-insensitive substring of the project label or path, and is repeatable with OR semantics; `--project-aliases key=Label,...` overrides project display labels (table headers only — never the JSON keys). Two distinct git-roots that share a basename stay separate (`app (work)` / `app (personal)`), null-project entries collect under `(unknown)`, and under `--format` shareable output `-i` is a no-op while `-p` is honored (the filter survives into the artifact). (#86)
17
+
8
18
  ## [1.17.0] - 2026-05-27
9
19
 
10
20
  ### Added
@@ -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
- - ``_get_codex_sessions_dir`` / ``_discover_codex_session_files`` —
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``, ``_get_codex_sessions_dir``,
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 _get_codex_sessions_dir() -> pathlib.Path | None:
269
- """Return the Codex sessions directory if present, else None."""
270
- c = _cctally()
271
- if c.CODEX_SESSIONS_DIR.is_dir():
272
- return c.CODEX_SESSIONS_DIR
273
- return None
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 ~/.codex/sessions/**/*.jsonl, filtering by mtime >= range_start."""
280
- root = _get_codex_sessions_dir()
281
- if root is None:
282
- eprint("[codex] no ~/.codex/sessions directory found")
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 root.glob("**/*.jsonl"):
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:
@@ -937,6 +964,13 @@ class _JoinedClaudeEntry:
937
964
  # reconciles with daily/range-cost paths that already pass
938
965
  # `entry.cost_usd` into `_calculate_entry_cost`.
939
966
  cost_usd: float | None = None
967
+ # Non-token `usage` extras (parsed `usage_extra_json`) — notably
968
+ # `speed`, which `_aggregate_buckets` reads to render `<model>-fast`.
969
+ # `iter_entries` merges these into its `UsageEntry.usage`; the joined
970
+ # path must carry them too so `_usage_entry_from_joined` can restore
971
+ # them (else `daily -i`/`-p` lose fast-tier model labels). None when
972
+ # the row has no extras.
973
+ usage_extra: dict | None = None
940
974
 
941
975
 
942
976
  def get_claude_session_entries(
@@ -996,7 +1030,7 @@ def get_claude_session_entries(
996
1030
  " se.cache_create_tokens, se.cache_read_tokens, "
997
1031
  " se.source_path, "
998
1032
  " sf.session_id, sf.project_path, "
999
- " se.cost_usd_raw "
1033
+ " se.cost_usd_raw, se.usage_extra_json "
1000
1034
  "FROM session_entries se "
1001
1035
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1002
1036
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -1024,6 +1058,7 @@ def get_claude_session_entries(
1024
1058
  session_id=row[7],
1025
1059
  project_path=row[8],
1026
1060
  cost_usd=row[9],
1061
+ usage_extra=(json.loads(row[10]) if row[10] else None),
1027
1062
  )
1028
1063
  for row in rows
1029
1064
  ]
@@ -1135,9 +1170,16 @@ def _direct_parse_claude_session_entries(
1135
1170
  results: list[_JoinedClaudeEntry] = []
1136
1171
  flat: list[tuple[UsageEntry, str]] = list(dedupe_map.values()) + no_key_with_meta
1137
1172
  flat.sort(key=lambda pair: pair[0].timestamp)
1173
+ _token_keys = {
1174
+ "input_tokens", "output_tokens",
1175
+ "cache_creation_input_tokens", "cache_read_input_tokens",
1176
+ }
1138
1177
  for entry, source_path in flat:
1139
1178
  usage = entry.usage
1140
1179
  sid, cwd = meta_by_path[source_path]
1180
+ # Mirror the cache-backed path: carry non-token `usage` extras
1181
+ # (e.g. `speed`) so `_usage_entry_from_joined` can restore them.
1182
+ extras = {k: v for k, v in usage.items() if k not in _token_keys}
1141
1183
  results.append(_JoinedClaudeEntry(
1142
1184
  timestamp=entry.timestamp,
1143
1185
  model=entry.model,
@@ -1153,6 +1195,7 @@ def _direct_parse_claude_session_entries(
1153
1195
  session_id=sid,
1154
1196
  project_path=cwd,
1155
1197
  cost_usd=entry.cost_usd,
1198
+ usage_extra=(extras or None),
1156
1199
  ))
1157
1200
 
1158
1201
  return results
@@ -1174,6 +1217,9 @@ class CodexIngestStats:
1174
1217
  # ``IngestStats`` (Claude path) which carries an UPSERT and
1175
1218
  # therefore counts both new INSERTs and DO UPDATE replacements.
1176
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
1177
1223
  lock_contended: bool = False
1178
1224
 
1179
1225
 
@@ -1227,14 +1273,52 @@ def sync_codex_cache(
1227
1273
  conn.commit()
1228
1274
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
1229
1275
 
1230
- root = _get_codex_sessions_dir()
1231
- paths: list[pathlib.Path] = []
1232
- if root is not None:
1233
- for jp in root.glob("**/*.jsonl"):
1234
- if jp.is_file():
1235
- 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))
1236
1280
  stats.files_total = len(paths)
1237
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
+
1238
1322
  # This SELECT does NOT open an implicit transaction (Python's
1239
1323
  # sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
1240
1324
  # UPDATE/DELETE/REPLACE statement between here and the per-file
@@ -232,6 +232,41 @@ def _aggregate_monthly(
232
232
  )
233
233
 
234
234
 
235
+ def _aggregate_daily_by_project(
236
+ keyed_entries: list[tuple[Any, UsageEntry]],
237
+ *,
238
+ tz: "Any | None" = None,
239
+ mode: str = "auto",
240
+ ) -> list[tuple[Any, list[BucketUsage]]]:
241
+ """Group ``(project_key, UsageEntry)`` pairs into per-project daily buckets.
242
+
243
+ Returns ``[(project_key, [BucketUsage date-asc]), ...]`` ordered by each
244
+ project's total cost descending, ties broken by ``project_key.display_key``
245
+ ascending. ``project_key`` is opaque/hashable (a ``ProjectKey``); resolution
246
+ happened in the caller, so this stays pure (no filesystem).
247
+
248
+ Reuses ``_aggregate_daily`` per group, so per-model breakdowns, token sums,
249
+ and ``mode``/``cost_usd`` threading are identical to the non-instances path.
250
+ """
251
+ grouped: dict[Any, list[UsageEntry]] = {}
252
+ order: list[Any] = []
253
+ for key, entry in keyed_entries:
254
+ bucket = grouped.get(key)
255
+ if bucket is None:
256
+ grouped[key] = bucket = []
257
+ order.append(key)
258
+ bucket.append(entry)
259
+
260
+ ranked: list[tuple[Any, list[BucketUsage], float]] = []
261
+ for key in order:
262
+ buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
263
+ total = sum(b.cost_usd for b in buckets)
264
+ ranked.append((key, buckets, total))
265
+
266
+ ranked.sort(key=lambda t: (-t[2], t[0].display_key))
267
+ return [(key, buckets) for key, buckets, _ in ranked]
268
+
269
+
235
270
  def _aggregate_weekly(
236
271
  entries: list[UsageEntry],
237
272
  weeks: list[SubWeek],
@@ -340,6 +375,14 @@ class CodexSessionUsage:
340
375
  models: list[str]
341
376
  model_breakdowns: list[dict[str, Any]]
342
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 = ""
343
386
 
344
387
 
345
388
  @dataclass
@@ -496,24 +539,30 @@ def _aggregate_codex_weekly(
496
539
  def _session_path_parts(source_path: str) -> tuple[str, str, str]:
497
540
  """Return (session_id_path, session_file, directory) from a full path.
498
541
 
499
- session_id_path = relative path under CODEX_SESSIONS_DIR with .jsonl
500
- 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-...").
501
544
  session_file = basename without .jsonl extension.
502
- directory = relative parent path under CODEX_SESSIONS_DIR.
503
-
504
- Accepts three input shapes:
505
- 1. Absolute path under CODEX_SESSIONS_DIR (the runtime sync path).
506
- 2. Bare-relative path starting with ".codex/sessions/..." the form
507
- emitted by build-codex-fixtures.py so committed fixture cache.db
508
- files stay free of maintainer absolute paths (public-mirror safe).
509
- 3. Anything else falls back to basename-only.
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).
510
554
  """
511
- CODEX_SESSIONS_DIR = _cctally().CODEX_SESSIONS_DIR
555
+ roots = _cctally()._codex_session_roots()
512
556
  p = pathlib.Path(source_path)
513
- try:
514
- rel = p.relative_to(CODEX_SESSIONS_DIR)
515
- except ValueError:
516
- # Try bare-relative ".codex/sessions/<rest>" before basename fallback.
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.
517
566
  # Use PurePosixPath to avoid Windows-style drive parsing on unusual
518
567
  # inputs; fixture-emitted paths are always POSIX.
519
568
  parts = pathlib.PurePosixPath(source_path).parts
@@ -525,6 +574,22 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
525
574
  return str(stem), stem.name, str(stem.parent)
526
575
 
527
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
+
528
593
  def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
529
594
  """Group by session file path (upstream-compatible).
530
595
 
@@ -535,13 +600,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
535
600
  Per-model breakdowns include `isFallback: bool` — true when the model is
536
601
  absent from CODEX_MODEL_PRICING.
537
602
  """
538
- by_session: dict[str, dict[str, Any]] = {}
603
+ by_session: dict[tuple[str, str], dict[str, Any]] = {}
539
604
  for entry in entries:
540
605
  id_path, file_name, directory = _session_path_parts(entry.source_path)
541
- sess = by_session.setdefault(id_path, {
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), {
542
620
  "session_id_uuid": entry.session_id,
621
+ "session_id_path": id_path,
543
622
  "session_file": file_name,
544
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),
545
628
  "input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
546
629
  "cost": 0.0, "models": {}, "models_order": [],
547
630
  "last": entry.timestamp,
@@ -571,7 +654,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
571
654
  sess["last"] = entry.timestamp
572
655
 
573
656
  result: list[CodexSessionUsage] = []
574
- for id_path, s in by_session.items():
657
+ for _group_key, s in by_session.items():
575
658
  model_breakdowns = [
576
659
  {
577
660
  "modelName": model,
@@ -588,7 +671,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
588
671
  model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
589
672
  result.append(CodexSessionUsage(
590
673
  session_id=s["session_id_uuid"],
591
- session_id_path=id_path,
674
+ session_id_path=s["session_id_path"],
592
675
  session_file=s["session_file"],
593
676
  directory=s["directory"],
594
677
  input_tokens=s["input"],
@@ -606,6 +689,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
606
689
  models=list(s["models_order"]),
607
690
  model_breakdowns=model_breakdowns,
608
691
  last_activity=s["last"],
692
+ codex_root=s["codex_root"],
609
693
  ))
610
694
  result.sort(key=lambda x: x.last_activity, reverse=True)
611
695
  return result
@@ -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 _blocks_to_json(blocks: list[Block]) -> str:
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)
@@ -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 ~/.codex/sessions/)",
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
  )