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 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
@@ -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:
@@ -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
- root = _get_codex_sessions_dir()
1247
- paths: list[pathlib.Path] = []
1248
- if root is not None:
1249
- for jp in root.glob("**/*.jsonl"):
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
@@ -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 CODEX_SESSIONS_DIR with .jsonl
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 CODEX_SESSIONS_DIR.
538
-
539
- Accepts three input shapes:
540
- 1. Absolute path under CODEX_SESSIONS_DIR (the runtime sync path).
541
- 2. Bare-relative path starting with ".codex/sessions/..." the form
542
- emitted by build-codex-fixtures.py so committed fixture cache.db
543
- files stay free of maintainer absolute paths (public-mirror safe).
544
- 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).
545
554
  """
546
- CODEX_SESSIONS_DIR = _cctally().CODEX_SESSIONS_DIR
555
+ roots = _cctally()._codex_session_roots()
547
556
  p = pathlib.Path(source_path)
548
- try:
549
- rel = p.relative_to(CODEX_SESSIONS_DIR)
550
- except ValueError:
551
- # 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.
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
- 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), {
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 id_path, s in by_session.items():
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=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
@@ -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
  )
@@ -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
- local = ts.astimezone(tz)
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
- total_minutes = int(total_seconds / 60)
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 = 0
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
- show_pct = max_completed_tokens > 0
321
- if show_pct:
322
- token_limit = max_completed_tokens
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 token_limit > 0:
409
- pct_val = (block.total_tokens / token_limit) * 100.0
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 token_limit > 0:
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(token_limit - active_tokens, 0)
442
- remaining_pct = (remaining_tokens / token_limit) * 100.0
443
- rem_label = f"(assuming {_fmt_num(token_limit)} token limit)"
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 / token_limit) * 100.0 if token_limit > 0 else 0
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(banner_top)
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
- for s in sessions:
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
- session_list.append({
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
- for s in sessions:
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 ``~/.codex/config.toml`` requests the fast/priority tier.
4975
-
4976
- Reads from ``~/.codex`` only (single root; ``$CODEX_HOME`` multi-root is
4977
- deferred see #108). Tolerates an absent/unreadable config (→ False →
4978
- standard tier).
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
- cfg = pathlib.Path.home() / ".codex" / "config.toml"
4981
- try:
4982
- content = cfg.read_text(encoding="utf-8", errors="replace")
4983
- except OSError:
4984
- return False
4985
- return _codex_config_requests_fast_service_tier(content)
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 ``~/.codex/config.toml`` requests it, else
4992
- ``standard``. ``fast``/``standard`` pass through unchanged.
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 = pathlib.Path.home() / ".codex" / "sessions"
9836
- if codex_dir.exists():
9837
- codex_jsonl_present = next(codex_dir.glob("**/*.jsonl"), None) is not None
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.18.0",
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": {