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/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
@@ -368,6 +370,7 @@ CodexSessionUsage = _lib_aggregators.CodexSessionUsage
368
370
  ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
369
371
  _aggregate_buckets = _lib_aggregators._aggregate_buckets
370
372
  _aggregate_daily = _lib_aggregators._aggregate_daily
373
+ _aggregate_daily_by_project = _lib_aggregators._aggregate_daily_by_project
371
374
  _aggregate_monthly = _lib_aggregators._aggregate_monthly
372
375
  _aggregate_weekly = _lib_aggregators._aggregate_weekly
373
376
  _aggregate_codex_buckets = _lib_aggregators._aggregate_codex_buckets
@@ -408,7 +411,11 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
408
411
  _lib_render = _load_sibling("_lib_render")
409
412
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
410
413
  _render_blocks_table = _lib_render._render_blocks_table
414
+ _render_active_block_box = _lib_render._render_active_block_box
415
+ _daily_row_dict = _lib_render._daily_row_dict
416
+ _bucket_totals_dict = _lib_render._bucket_totals_dict
411
417
  _bucket_to_json = _lib_render._bucket_to_json
418
+ _bucket_by_project_to_json = _lib_render._bucket_by_project_to_json
412
419
  _weekly_to_json = _lib_render._weekly_to_json
413
420
  _daily_compact_split = _lib_render._daily_compact_split
414
421
  _monthly_compact_split = _lib_render._monthly_compact_split
@@ -614,7 +621,6 @@ cmd_db_unskip = _cctally_db.cmd_db_unskip
614
621
  _cctally_cache = _load_sibling("_cctally_cache")
615
622
  ProjectKey = _cctally_cache.ProjectKey
616
623
  _resolve_project_key = _cctally_cache._resolve_project_key
617
- _get_codex_sessions_dir = _cctally_cache._get_codex_sessions_dir
618
624
  _discover_codex_session_files = _cctally_cache._discover_codex_session_files
619
625
  IngestStats = _cctally_cache.IngestStats
620
626
  _progress_stderr = _cctally_cache._progress_stderr
@@ -871,6 +877,84 @@ LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
871
877
 
872
878
  CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
873
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
+
874
958
  # Note: `_cctally_db` reads its four path constants
875
959
  # (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
876
960
  # `_cctally_core.X` at call time — the canonical sibling pattern after
@@ -1538,16 +1622,27 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
1538
1622
  The joined-entry shape already carries ``source_path``, ``cost_usd``,
1539
1623
  and the per-token integers; this adapter is pure shape conversion
1540
1624
  with no cache re-read.
1625
+
1626
+ Non-token ``usage`` extras (``je.usage_extra``) — notably ``speed`` —
1627
+ are merged AFTER the four token keys, mirroring ``iter_entries``'
1628
+ ``usage.update(json.loads(...))``. Without this, the project-axis
1629
+ ``daily`` path (and the diff/report joined-entry consumers) would
1630
+ drop the fast-tier flag and render ``<model>`` where the normal path
1631
+ renders ``<model>-fast``. The write-side strips the four token keys
1632
+ from ``usage_extra_json`` so the merge never shadows the integers.
1541
1633
  """
1634
+ usage = {
1635
+ "input_tokens": je.input_tokens,
1636
+ "output_tokens": je.output_tokens,
1637
+ "cache_creation_input_tokens": je.cache_creation_tokens,
1638
+ "cache_read_input_tokens": je.cache_read_tokens,
1639
+ }
1640
+ if je.usage_extra:
1641
+ usage.update(je.usage_extra)
1542
1642
  return UsageEntry(
1543
1643
  timestamp=je.timestamp,
1544
1644
  model=je.model,
1545
- usage={
1546
- "input_tokens": je.input_tokens,
1547
- "output_tokens": je.output_tokens,
1548
- "cache_creation_input_tokens": je.cache_creation_tokens,
1549
- "cache_read_input_tokens": je.cache_read_tokens,
1550
- },
1645
+ usage=usage,
1551
1646
  cost_usd=je.cost_usd,
1552
1647
  source_path=je.source_path,
1553
1648
  )
@@ -1586,6 +1681,42 @@ def _project_filter_matches(key, project_patterns):
1586
1681
  return any((p in dname) or (p in pname) for p in project_patterns)
1587
1682
 
1588
1683
 
1684
+ def _parse_project_aliases(raw):
1685
+ """Parse a ``--project-aliases`` value into ``{key: label}``.
1686
+
1687
+ Form: comma-separated ``key=Label`` pairs. Whitespace around keys, labels,
1688
+ and pairs is stripped; segments without ``=`` or with an empty key/label are
1689
+ dropped (ported from ccusage's tolerant parser). ``None``/"" → ``{}``.
1690
+ """
1691
+ result: dict[str, str] = {}
1692
+ if not raw:
1693
+ return result
1694
+ for pair in raw.split(","):
1695
+ pair = pair.strip()
1696
+ if not pair or "=" not in pair:
1697
+ continue
1698
+ k, _, v = pair.partition("=")
1699
+ k = k.strip()
1700
+ v = v.strip()
1701
+ if k and v:
1702
+ result[k] = v
1703
+ return result
1704
+
1705
+
1706
+ def _alias_for(key, aliases):
1707
+ """Return the alias label for a ProjectKey, or None.
1708
+
1709
+ Looks up ``aliases`` by ``display_key``, then ``git_root``, then
1710
+ ``bucket_path`` (first hit). Display-only — never alters JSON keys.
1711
+ """
1712
+ if not aliases:
1713
+ return None
1714
+ for cand in (key.display_key, key.git_root, key.bucket_path):
1715
+ if cand and cand in aliases:
1716
+ return aliases[cand]
1717
+ return None
1718
+
1719
+
1589
1720
  def _emit_diff_debug_samples(args, window_a, window_b) -> None:
1590
1721
  """Two-window diff report (spec §7.2.2 Pattern D).
1591
1722
 
@@ -4172,6 +4303,15 @@ def _load_recorded_five_hour_windows(
4172
4303
 
4173
4304
  def cmd_blocks(args: argparse.Namespace) -> int:
4174
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
+
4175
4315
  config = _load_claude_config_for_args(args)
4176
4316
  _bridge_z_into_tz(args, config)
4177
4317
  tz = resolve_display_tz(args, config)
@@ -4271,16 +4411,60 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4271
4411
  # 20:50→01:50 with $45 cost vs the real $128).
4272
4412
  _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
4273
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
+
4274
4447
  if args.json:
4275
- 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
+ ))
4276
4457
  return 0
4277
4458
 
4278
4459
  # Table output. Session A (spec §7.6.1; Review-A P2-B): thread
4279
4460
  # --compact through so the renderer's scale-down branch fires
4280
- # 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).
4281
4465
  print(_render_blocks_table(
4282
4466
  blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
4283
- compact=getattr(args, "compact", False),
4467
+ compact=getattr(args, "compact", False), token_limit=token_limit,
4284
4468
  ))
4285
4469
  return 0
4286
4470
 
@@ -4512,6 +4696,29 @@ def _parse_cli_date_range(
4512
4696
  return range_start, range_end
4513
4697
 
4514
4698
 
4699
+ def _emit_daily_view_table_or_json(view, args):
4700
+ """Order + emit a DailyView as the flat daily table or {daily} JSON.
4701
+
4702
+ Shared by cmd_daily's default path and its -p-only (filter, no grouping)
4703
+ path so the two cannot drift. Body is exactly the default path's order +
4704
+ emit tail; callers keep their own --format share gate upstream of this.
4705
+ """
4706
+ days = list(reversed(view.aggregated))
4707
+ if args.order == "desc":
4708
+ days = list(reversed(days))
4709
+ if args.json:
4710
+ print(_bucket_to_json(days, list_key="daily", date_key="date"))
4711
+ return
4712
+ print(_render_bucket_table(
4713
+ days,
4714
+ first_col_name="Date",
4715
+ title_suffix="Daily",
4716
+ compact_split_fn=_daily_compact_split,
4717
+ breakdown=args.breakdown,
4718
+ compact=getattr(args, "compact", False),
4719
+ ))
4720
+
4721
+
4515
4722
  def cmd_daily(args: argparse.Namespace) -> int:
4516
4723
  """Show usage report grouped by display-timezone date."""
4517
4724
  _share_validate_args(args)
@@ -4533,6 +4740,109 @@ def cmd_daily(args: argparse.Namespace) -> int:
4533
4740
  return range
4534
4741
  range_start, range_end = range
4535
4742
 
4743
+ # ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
4744
+ # Gated by -i/--instances or -p/--project; the default path below is
4745
+ # untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
4746
+ # resolution + substring-OR-path filter.
4747
+ aliases = _parse_project_aliases(getattr(args, "project_aliases", None))
4748
+ project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
4749
+
4750
+ if getattr(args, "instances", False) or project_patterns:
4751
+ joined = list(get_claude_session_entries(range_start, range_end))
4752
+ resolver_cache: dict = {}
4753
+ keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
4754
+ filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
4755
+ for je in joined:
4756
+ if je.model == "<synthetic>":
4757
+ continue
4758
+ key = _resolve_project_key(je.project_path, "git-root", resolver_cache)
4759
+ if project_patterns and not _project_filter_matches(key, project_patterns):
4760
+ continue
4761
+ ue = _usage_entry_from_joined(je)
4762
+ keyed.append((key, ue))
4763
+ filtered_uentries.append(ue)
4764
+
4765
+ # Debug scope = the filtered entries (mirrors cmd_project).
4766
+ _emit_debug_samples_if_set(args, filtered_uentries, command_label="daily")
4767
+
4768
+ # --format share gate: -i is a no-op (no project-section share render),
4769
+ # but -p IS honored by building the snapshot from the filtered view.
4770
+ if getattr(args, "format", None):
4771
+ view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
4772
+ display_tz=tz, mode=args.mode)
4773
+ display_tz_str = _share_display_tz_label(tz)
4774
+ snap = _build_daily_snapshot(
4775
+ view, period_start=range_start, period_end=range_end,
4776
+ display_tz=display_tz_str, version=_share_resolve_version(),
4777
+ theme=args.theme, reveal_projects=args.reveal_projects,
4778
+ )
4779
+ if args.order == "desc":
4780
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
4781
+ _share_render_and_emit(snap, args)
4782
+ return 0
4783
+
4784
+ if getattr(args, "instances", False):
4785
+ groups = _aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
4786
+ aug = _project_disambiguate_labels(
4787
+ [{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
4788
+ for k, bl in groups]
4789
+ )
4790
+ json_groups: list = []
4791
+ table_groups: list = []
4792
+ # `_project_disambiguate_labels` only suffixes the immediate
4793
+ # parent-dir basename, so two distinct git-roots like
4794
+ # `/a/x/app` + `/b/x/app` both resolve to `app (x)`. Guarantee
4795
+ # per-group JSON-key uniqueness with a counter suffix on any
4796
+ # residual collision — otherwise `_bucket_by_project_to_json`'s
4797
+ # `projects[label] = ...` silently overwrites the earlier group
4798
+ # (data loss in --json). The table_label derives from the now-
4799
+ # unique json_label, so section headers stay distinct too.
4800
+ # `json_label`s are unique by construction (the `(#N)` counter
4801
+ # above). Table labels, however, can re-collide: `_alias_for`
4802
+ # matches on `display_key` first, so a basename alias like
4803
+ # `--project-aliases app=Alias` maps BOTH same-basename git-roots
4804
+ # to "Alias" — re-merging the exact sections this feature
4805
+ # disambiguates. Apply the SAME `(#N)` counter to table labels so
4806
+ # the two distinct-total sections stay tellable apart (JSON keys
4807
+ # are untouched — they use the non-aliased `json_label`).
4808
+ seen_json_labels: dict[str, int] = {}
4809
+ seen_table_labels: dict[str, int] = {}
4810
+ for i, (k, bl) in enumerate(groups):
4811
+ ordered = list(reversed(bl)) if args.order == "desc" else bl
4812
+ base_json_label = aug.get(i, k.display_key)
4813
+ n = seen_json_labels.get(base_json_label, 0) + 1
4814
+ seen_json_labels[base_json_label] = n
4815
+ json_label = (
4816
+ base_json_label if n == 1 else f"{base_json_label} (#{n})"
4817
+ )
4818
+ base_table_label = _alias_for(k, aliases) or json_label
4819
+ nt = seen_table_labels.get(base_table_label, 0) + 1
4820
+ seen_table_labels[base_table_label] = nt
4821
+ table_label = (
4822
+ base_table_label if nt == 1
4823
+ else f"{base_table_label} (#{nt})"
4824
+ )
4825
+ json_groups.append((json_label, ordered))
4826
+ table_groups.append((table_label, ordered))
4827
+ if args.json:
4828
+ print(_bucket_by_project_to_json(json_groups, date_key="date"))
4829
+ return 0
4830
+ print(_render_bucket_table(
4831
+ [], first_col_name="Date", title_suffix="Daily",
4832
+ compact_split_fn=_daily_compact_split,
4833
+ breakdown=args.breakdown,
4834
+ compact=getattr(args, "compact", False),
4835
+ project_groups=table_groups,
4836
+ ))
4837
+ return 0
4838
+
4839
+ # -p only (no -i): filter-only → normal date-aggregated daily output.
4840
+ view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
4841
+ display_tz=tz, mode=args.mode)
4842
+ _emit_daily_view_table_or_json(view, args)
4843
+ return 0
4844
+
4845
+ # ── Default path (UNCHANGED) ───────────────────────────────────────────
4536
4846
  # Collect entries.
4537
4847
  all_entries = get_entries(range_start, range_end)
4538
4848
 
@@ -4549,10 +4859,6 @@ def cmd_daily(args: argparse.Namespace) -> int:
4549
4859
  # `_aggregate_daily` call is the same one we used inline.
4550
4860
  view = build_daily_view(all_entries, now_utc=_command_as_of(),
4551
4861
  display_tz=tz, mode=args.mode)
4552
- # `_aggregate_daily` returned ascending order; build_daily_view stores
4553
- # `aggregated` newest-first. CLI's default order is ascending, so
4554
- # re-reverse to match the prior on-the-wire shape.
4555
- days = list(reversed(view.aggregated))
4556
4862
 
4557
4863
  # Shareable-reports gate: --format short-circuits the JSON / table
4558
4864
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -4583,23 +4889,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
4583
4889
  _share_render_and_emit(snap, args)
4584
4890
  return 0
4585
4891
 
4586
- # Apply sort order.
4587
- if args.order == "desc":
4588
- days = list(reversed(days))
4589
-
4590
- if args.json:
4591
- print(_bucket_to_json(days, list_key="daily", date_key="date"))
4592
- return 0
4593
-
4594
- # Table output.
4595
- print(_render_bucket_table(
4596
- days,
4597
- first_col_name="Date",
4598
- title_suffix="Daily",
4599
- compact_split_fn=_daily_compact_split,
4600
- breakdown=args.breakdown,
4601
- compact=getattr(args, "compact", False),
4602
- ))
4892
+ # Order + emit the flat daily table / {daily} JSON. Extracted into
4893
+ # `_emit_daily_view_table_or_json` so this default path and the
4894
+ # -p-only (filter, no grouping) path above stay byte-identical.
4895
+ _emit_daily_view_table_or_json(view, args)
4603
4896
  return 0
4604
4897
 
4605
4898
 
@@ -4811,25 +5104,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4811
5104
 
4812
5105
 
4813
5106
  def _detect_codex_fast_service_tier() -> bool:
4814
- """True iff ``~/.codex/config.toml`` requests the fast/priority tier.
4815
-
4816
- Reads from ``~/.codex`` only (single root; ``$CODEX_HOME`` multi-root is
4817
- deferred see #108). Tolerates an absent/unreadable config (→ False →
4818
- 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).
4819
5115
  """
4820
- cfg = pathlib.Path.home() / ".codex" / "config.toml"
4821
- try:
4822
- content = cfg.read_text(encoding="utf-8", errors="replace")
4823
- except OSError:
4824
- return False
4825
- 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
4826
5125
 
4827
5126
 
4828
5127
  def _resolve_codex_speed(requested: str) -> str:
4829
5128
  """Resolve a ``--speed`` value to an effective tier.
4830
5129
 
4831
- ``auto`` → ``fast`` iff ``~/.codex/config.toml`` requests it, else
4832
- ``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.
4833
5133
  """
4834
5134
  if requested == "auto":
4835
5135
  return "fast" if _detect_codex_fast_service_tier() else "standard"
@@ -9670,11 +9970,16 @@ def doctor_gather_state(
9670
9970
  except Exception:
9671
9971
  pass
9672
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.
9673
9977
  codex_jsonl_present = False
9674
9978
  try:
9675
- codex_dir = pathlib.Path.home() / ".codex" / "sessions"
9676
- if codex_dir.exists():
9677
- 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
9678
9983
  except Exception:
9679
9984
  pass
9680
9985
 
@@ -10173,6 +10478,8 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10173
10478
  cctally daily --since 20260414 --breakdown
10174
10479
  cctally daily --since 20260414 --json
10175
10480
  cctally daily --order desc
10481
+ cctally daily --instances
10482
+ cctally daily -i --project-aliases repos=Repos
10176
10483
  """),
10177
10484
  )
10178
10485
  p.add_argument(
@@ -10210,6 +10517,28 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10210
10517
  help="Display timezone: local, utc, or IANA name. "
10211
10518
  "Overrides config display.tz for this call.",
10212
10519
  )
10520
+ p.add_argument(
10521
+ "-i", "--instances",
10522
+ action="store_true",
10523
+ default=False,
10524
+ help="Group the report by project (git-root).",
10525
+ )
10526
+ p.add_argument(
10527
+ "-p", "--project",
10528
+ action="append",
10529
+ default=None,
10530
+ metavar="PATTERN",
10531
+ help="Filter to projects matching PATTERN (substring of the project "
10532
+ "label or path; repeatable, OR semantics).",
10533
+ )
10534
+ p.add_argument(
10535
+ "--project-aliases",
10536
+ dest="project_aliases",
10537
+ default=None,
10538
+ metavar="PAIRS",
10539
+ help="Comma-separated key=Label pairs overriding project display "
10540
+ "labels (e.g. cctally-dev=Tracker). Display-only.",
10541
+ )
10213
10542
  _add_ccusage_alias_args(p, ansi_emit=False)
10214
10543
  _add_mode_arg(p)
10215
10544
  _add_share_args(p)
@@ -10417,6 +10746,29 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
10417
10746
  help="Display timezone: local, utc, or IANA name. "
10418
10747
  "Overrides config display.tz for this call.",
10419
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
+ )
10420
10772
  _add_ccusage_alias_args(p, ansi_emit=False)
10421
10773
  _add_mode_arg(p)
10422
10774
  p.set_defaults(func=cmd_blocks)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.17.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": {