cctally 1.18.0 → 1.20.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
@@ -150,6 +150,7 @@ SETUP_SYMLINK_NAMES = (
150
150
  "cctally-forecast",
151
151
  "cctally-project",
152
152
  "cctally-refresh-usage",
153
+ "cctally-statusline",
153
154
  "cctally-sync-week",
154
155
  "cctally-tui",
155
156
  "cctally-update",
@@ -274,6 +275,26 @@ CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
274
275
  _codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
275
276
  _short_model_name = _lib_pricing._short_model_name
276
277
 
278
+ # Per-model context window (used by `cctally statusline` segment 4).
279
+ # Keep in sync with Anthropic's docs:
280
+ # https://docs.anthropic.com/en/docs/about-claude/models
281
+ # Unknown model id → segment renders `🧠 N/A` + one-shot stderr warn.
282
+ CLAUDE_MODEL_CONTEXT_WINDOWS = {
283
+ # 1M-token variants (explicit IDs override the family default).
284
+ "claude-opus-4-7[1m]": 1_000_000,
285
+ "claude-sonnet-4-5[1m]": 1_000_000,
286
+ # Default 200K for every other Sonnet/Opus/Haiku family member.
287
+ # The resolver does a substring match on the family token if the
288
+ # exact id is missing — see _resolve_context_window.
289
+ }
290
+
291
+ CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY = {
292
+ # Substring (case-insensitive) → window. Order matters; first hit wins.
293
+ "sonnet": 200_000,
294
+ "opus": 200_000,
295
+ "haiku": 200_000,
296
+ }
297
+
277
298
  _lib_display_tz = _load_sibling("_lib_display_tz")
278
299
  DISPLAY_TZ_DEFAULT = _lib_display_tz.DISPLAY_TZ_DEFAULT
279
300
  _DISPLAY_TZ_BAD_CONFIG_WARNED = _lib_display_tz._DISPLAY_TZ_BAD_CONFIG_WARNED
@@ -349,6 +370,11 @@ _group_entries_into_blocks = _lib_blocks._group_entries_into_blocks
349
370
  _aggregate_block = _lib_blocks._aggregate_block
350
371
  _build_activity_block = _lib_blocks._build_activity_block
351
372
  _blocks_to_json = _lib_blocks._blocks_to_json
373
+ _max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
374
+ _parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
375
+
376
+ _lib_statusline = _load_sibling("_lib_statusline")
377
+ STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
352
378
 
353
379
  _lib_changelog = _load_sibling("_lib_changelog")
354
380
  # Backward-compat re-export: callers and tests reach the helper through
@@ -409,6 +435,7 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
409
435
  _lib_render = _load_sibling("_lib_render")
410
436
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
411
437
  _render_blocks_table = _lib_render._render_blocks_table
438
+ _render_active_block_box = _lib_render._render_active_block_box
412
439
  _daily_row_dict = _lib_render._daily_row_dict
413
440
  _bucket_totals_dict = _lib_render._bucket_totals_dict
414
441
  _bucket_to_json = _lib_render._bucket_to_json
@@ -618,7 +645,6 @@ cmd_db_unskip = _cctally_db.cmd_db_unskip
618
645
  _cctally_cache = _load_sibling("_cctally_cache")
619
646
  ProjectKey = _cctally_cache.ProjectKey
620
647
  _resolve_project_key = _cctally_cache._resolve_project_key
621
- _get_codex_sessions_dir = _cctally_cache._get_codex_sessions_dir
622
648
  _discover_codex_session_files = _cctally_cache._discover_codex_session_files
623
649
  IngestStats = _cctally_cache.IngestStats
624
650
  _progress_stderr = _cctally_cache._progress_stderr
@@ -875,6 +901,84 @@ LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
875
901
 
876
902
  CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
877
903
 
904
+
905
+ def _codex_home_roots() -> list[pathlib.Path]:
906
+ """ALL raw $CODEX_HOME entries (comma-split), else [~/.codex].
907
+
908
+ These are the entries upstream calls "CODEX_HOME roots" — the full
909
+ comma-split list, BEFORE the sessions/-subdir rule decides home-vs-direct.
910
+ The config reader (_detect_codex_fast_service_tier) reads <entry>/config.toml
911
+ for EVERY entry returned here, including ones that turn out to be direct
912
+ JSONL dirs; only _codex_session_roots() applies the sessions/-subdir
913
+ narrowing. Blank/whitespace entries are dropped; each is expanduser'd
914
+ (literal '~'; the shell already expands $VAR before we see the value)
915
+ then made ABSOLUTE via .absolute() — a relative $CODEX_HOME (e.g.
916
+ `./codexA`) would otherwise glob and store relative source_paths, which
917
+ the cache-prune step cannot distinguish from synthetic relative-path
918
+ fixture rows (issue #108; see the prune guard in _cctally_cache.py).
919
+ Canonicalizing here makes every REAL ingested source_path absolute, so
920
+ the prune's `isabs` fixture carve-out is correct by construction.
921
+ Order is preserved (first-match-wins downstream).
922
+ """
923
+ raw = os.environ.get("CODEX_HOME", "").strip()
924
+ if not raw:
925
+ return [pathlib.Path.home() / ".codex"]
926
+ roots: list[pathlib.Path] = []
927
+ saw_part = False
928
+ for part in raw.split(","):
929
+ part = part.strip()
930
+ if not part:
931
+ continue
932
+ saw_part = True
933
+ try:
934
+ # expanduser() raises RuntimeError on a malformed `~user` entry
935
+ # (e.g. `~nonexistentuser/x` — the user doesn't exist). Drop the
936
+ # bad entry rather than aborting the whole command, so valid
937
+ # roots beside it survive. .absolute() canonicalizes a relative
938
+ # entry (e.g. `./codexA`) against cwd so real ingested source_paths
939
+ # are always absolute (issue #108 — keeps the cache-prune correct).
940
+ roots.append(pathlib.Path(part).expanduser().absolute())
941
+ except (RuntimeError, OSError, ValueError):
942
+ continue
943
+ # Fall back to the default home ONLY when the variable is unset or carries
944
+ # no non-blank entry at all. An explicit-but-all-invalid value (e.g. a lone
945
+ # `~baduser` that expanduser() drops) yields [] — respect the override and
946
+ # read nothing rather than silently reading the default account (issue
947
+ # #108). A plain dead path like `/typo` already reaches [] via the is_dir()
948
+ # filter in _codex_session_roots(); this aligns the `~user` flavor with it.
949
+ if not saw_part:
950
+ return [pathlib.Path.home() / ".codex"]
951
+ return roots
952
+
953
+
954
+ def _codex_session_roots() -> list[pathlib.Path]:
955
+ """Directories to walk for *.jsonl, applying the sessions/-subdir rule.
956
+
957
+ For each home root r (in $CODEX_HOME order):
958
+ (r / "sessions").is_dir() -> r / "sessions" (Codex home)
959
+ elif r.is_dir() -> r (direct JSONL dir)
960
+ else -> skipped
961
+ Exact-duplicate roots are de-duped (first occurrence kept). File-level
962
+ de-dup for overlapping/prefix roots happens in the discovery walkers
963
+ (set of absolute paths); this function only collapses identical roots.
964
+ """
965
+ out: list[pathlib.Path] = []
966
+ seen: set[pathlib.Path] = set()
967
+ for r in _codex_home_roots():
968
+ sess = r / "sessions"
969
+ if sess.is_dir():
970
+ cand = sess
971
+ elif r.is_dir():
972
+ cand = r
973
+ else:
974
+ continue
975
+ if cand in seen:
976
+ continue
977
+ seen.add(cand)
978
+ out.append(cand)
979
+ return out
980
+
981
+
878
982
  # Note: `_cctally_db` reads its four path constants
879
983
  # (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
880
984
  # `_cctally_core.X` at call time — the canonical sibling pattern after
@@ -4223,6 +4327,15 @@ def _load_recorded_five_hour_windows(
4223
4327
 
4224
4328
  def cmd_blocks(args: argparse.Namespace) -> int:
4225
4329
  """Show usage report grouped by 5-hour session blocks."""
4330
+ # -n/--session-length guard (#86 Session F). The flag is a documented
4331
+ # no-op (cctally blocks anchor to Anthropic's real 5h resets and are not
4332
+ # re-sizable), but a non-positive value still errors for drop-in fidelity
4333
+ # with ccusage's "Session length must be a positive number". Runs first,
4334
+ # before any data load — matches ccusage's command-flow ordering.
4335
+ if getattr(args, "session_length", 5.0) <= 0:
4336
+ eprint("blocks: session length must be a positive number")
4337
+ return 1
4338
+
4226
4339
  config = _load_claude_config_for_args(args)
4227
4340
  _bridge_z_into_tz(args, config)
4228
4341
  tz = resolve_display_tz(args, config)
@@ -4322,20 +4435,567 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4322
4435
  # 20:50→01:50 with $45 cost vs the real $128).
4323
4436
  _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
4324
4437
 
4438
+ # ── Session F (#86): resolve token limit, then filter ────────────────
4439
+ # Auto-max baseline over ALL blocks (before --recent/--active filtering),
4440
+ # matching ccusage's maxTokensFromAll.
4441
+ max_completed = _max_completed_block_tokens(blocks)
4442
+ token_limit = _parse_blocks_token_limit(
4443
+ getattr(args, "token_limit", None), max_completed
4444
+ )
4445
+ # ``token_limit_explicit`` is the resolved limit ONLY when -t was passed
4446
+ # (any value incl. "max"); the implicit default leaves it None so the
4447
+ # box's Token Limit Status sub-block + the JSON tokenLimitStatus key are
4448
+ # omitted (ccusage `if (tokenLimit != null)` gate).
4449
+ token_limit_explicit = (
4450
+ token_limit if getattr(args, "token_limit", None) is not None else None
4451
+ )
4452
+ auto_max = getattr(args, "token_limit", None) in (None, "", "max")
4453
+ if auto_max and token_limit and not args.json:
4454
+ # ccusage parity: logger.info → stdout (Codex F1). Suppressed under
4455
+ # --json (ccusage sets logger.level=0), so --json goldens stay stable.
4456
+ print(f"Using max tokens from previous sessions: {_fmt_num(token_limit)}")
4457
+
4458
+ if getattr(args, "recent", False):
4459
+ cutoff = now_utc - dt.timedelta(days=3)
4460
+ blocks = [b for b in blocks if b.start_time >= cutoff or b.is_active]
4461
+
4462
+ if getattr(args, "active", False):
4463
+ blocks = [b for b in blocks if b.is_active and not b.is_gap]
4464
+ if not blocks:
4465
+ if args.json:
4466
+ print('{\n "blocks": [],\n "message": "No active block"\n}')
4467
+ else:
4468
+ print("No active session block found.")
4469
+ return 0
4470
+
4325
4471
  if args.json:
4326
- print(_blocks_to_json(blocks))
4472
+ print(_blocks_to_json(blocks, token_limit_status_limit=token_limit_explicit))
4473
+ return 0
4474
+
4475
+ if getattr(args, "active", False) and len(blocks) == 1:
4476
+ print(_render_active_block_box(
4477
+ blocks[0], now=now_utc, tz=tz,
4478
+ token_limit_explicit=token_limit_explicit,
4479
+ color=_supports_color_stdout(), unicode_ok=_supports_unicode_stdout(),
4480
+ ))
4327
4481
  return 0
4328
4482
 
4329
4483
  # Table output. Session A (spec §7.6.1; Review-A P2-B): thread
4330
4484
  # --compact through so the renderer's scale-down branch fires
4331
- # regardless of terminal width when the flag is set.
4485
+ # regardless of terminal width when the flag is set. Session F: thread
4486
+ # the resolved token_limit so an explicit -t keys the %/REMAINING/
4487
+ # PROJECTED surface (the default path passes the same auto-max the
4488
+ # renderer computed internally, so it stays byte-identical).
4332
4489
  print(_render_blocks_table(
4333
4490
  blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
4334
- compact=getattr(args, "compact", False),
4491
+ compact=getattr(args, "compact", False), token_limit=token_limit,
4335
4492
  ))
4336
4493
  return 0
4337
4494
 
4338
4495
 
4496
+ def cmd_statusline(args: argparse.Namespace) -> int:
4497
+ """`cctally statusline` — one-line status summary for CC hooks.
4498
+
4499
+ See docs/superpowers/specs/2026-05-28-issue-86-session-g-statusline-design.md
4500
+ for the full design.
4501
+
4502
+ Exit codes:
4503
+ 0 success (every absent stdin field degrades gracefully)
4504
+ 1 stdin is not parseable JSON OR root is not a JSON object
4505
+ 2 argparse rejected a flag (e.g. --cost-source ccusage), OR
4506
+ --config PATH unreadable
4507
+ """
4508
+ # NOTE: `--cost-source ccusage` is rejected at argparse-time by
4509
+ # `_CostSourceAction` in `_build_statusline_parser`; it exits 2 with
4510
+ # the rename hint before we get here, so no explicit re-check is
4511
+ # needed in this function.
4512
+
4513
+ # Validate `--context-{low,medium}-threshold` BEFORE reading stdin
4514
+ # so a misconfigured invocation fails fast without consuming the
4515
+ # CC hook's stdin payload.
4516
+ low = args.context_low_threshold
4517
+ med = args.context_medium_threshold
4518
+ if not isinstance(low, int) or low < 0 or low > 100:
4519
+ eprint(
4520
+ "cctally statusline: --context-low-threshold must be in [0, 100]"
4521
+ )
4522
+ return 2
4523
+ if not isinstance(med, int) or med < 0 or med > 100:
4524
+ eprint(
4525
+ "cctally statusline: --context-medium-threshold must be in [0, 100]"
4526
+ )
4527
+ return 2
4528
+ if low >= med:
4529
+ eprint(
4530
+ "cctally statusline: --context-low-threshold must be < "
4531
+ "--context-medium-threshold"
4532
+ )
4533
+ return 2
4534
+
4535
+ # Silently clamp `--refresh-interval` to [0, 600]. The flag is a
4536
+ # no-op alias for ccusage drop-in compat; users never observe the
4537
+ # effect, but the spec mandates the clamp for forward-compat (when
4538
+ # we promote it to a real flag, the clamped value should be the one
4539
+ # propagated downstream).
4540
+ try:
4541
+ args.refresh_interval = max(0, min(600, int(args.refresh_interval)))
4542
+ except (TypeError, ValueError):
4543
+ args.refresh_interval = 1
4544
+
4545
+ # Read stdin once.
4546
+ raw = sys.stdin.buffer.read()
4547
+ parse_result = _lib_statusline.parse_statusline_stdin(raw)
4548
+ if isinstance(parse_result, _lib_statusline.ParseError):
4549
+ eprint(f"cctally statusline: {parse_result.message}")
4550
+ return 1
4551
+ inp = parse_result
4552
+
4553
+ # Resolve effective config: CLI > config.json > built-in default.
4554
+ # `_load_claude_config_for_args` honors `--config PATH` (issue #88
4555
+ # plumbing); a missing/invalid PATH raises SystemExit(2) inside
4556
+ # `_load_config_from_explicit_path` so this call already enforces
4557
+ # exit-2 on a bad --config.
4558
+ cfg = _load_claude_config_for_args(args)
4559
+ sl_cfg = (cfg.get("statusline") or {}) if isinstance(cfg, dict) else {}
4560
+ if not isinstance(sl_cfg, dict):
4561
+ sl_cfg = {}
4562
+
4563
+ # Validate config values; on invalid, one-shot stderr warn + use default.
4564
+ _warned: set = set()
4565
+
4566
+ def warn_once(msg: str) -> None:
4567
+ if msg in _warned:
4568
+ return
4569
+ _warned.add(msg)
4570
+ eprint(msg)
4571
+
4572
+ def _resolve(cli_val, cfg_key, default):
4573
+ if cli_val is not None:
4574
+ return cli_val
4575
+ cv = sl_cfg.get(cfg_key)
4576
+ if cv is None:
4577
+ return default
4578
+ return cv
4579
+
4580
+ vbr = _resolve(args.visual_burn_rate, "visual_burn_rate", "off")
4581
+ if vbr not in ("off", "emoji", "text", "emoji-text"):
4582
+ warn_once(
4583
+ f"cctally statusline: invalid statusline.visual_burn_rate={vbr!r}; "
4584
+ f"using 'off'"
4585
+ )
4586
+ vbr = "off"
4587
+
4588
+ cs = _resolve(args.cost_source, "cost_source", "auto")
4589
+ if cs not in ("auto", "cctally", "cc", "both"):
4590
+ warn_once(
4591
+ f"cctally statusline: invalid statusline.cost_source={cs!r}; "
4592
+ f"using 'auto'"
4593
+ )
4594
+ cs = "auto"
4595
+
4596
+ ext_on = _resolve(args.cctally_extensions, "cctally_extensions", True)
4597
+ if not isinstance(ext_on, bool):
4598
+ warn_once(
4599
+ f"cctally statusline: invalid statusline.cctally_extensions="
4600
+ f"{ext_on!r}; using True"
4601
+ )
4602
+ ext_on = True
4603
+
4604
+ # Display tz: CLI -z/--timezone > display.tz config > "UTC".
4605
+ tz_name = getattr(args, "timezone", None)
4606
+ if not tz_name:
4607
+ display_block = cfg.get("display") if isinstance(cfg, dict) else None
4608
+ if isinstance(display_block, dict):
4609
+ tz_name = display_block.get("tz")
4610
+ if not tz_name:
4611
+ tz_name = "UTC"
4612
+ if tz_name in ("local", "LOCAL"):
4613
+ try:
4614
+ tz_name = _local_tz_name() or "UTC"
4615
+ except Exception:
4616
+ tz_name = "UTC"
4617
+ # Validate the IANA name; fall back to UTC with a one-shot warning.
4618
+ try:
4619
+ ZoneInfo(tz_name)
4620
+ except (ZoneInfoNotFoundError, Exception):
4621
+ warn_once(
4622
+ f"cctally statusline: invalid timezone {tz_name!r}; using 'UTC'"
4623
+ )
4624
+ tz_name = "UTC"
4625
+
4626
+ # Color: explicit CLI > NO_COLOR env > TTY detect.
4627
+ if args.color is True or args.color is False:
4628
+ color = args.color
4629
+ else:
4630
+ color = (os.environ.get("NO_COLOR", "") == "") and sys.stdout.isatty()
4631
+
4632
+ sargs = _lib_statusline.StatuslineArgs(
4633
+ visual_burn_rate=vbr,
4634
+ cost_source=cs,
4635
+ context_low_threshold=int(args.context_low_threshold),
4636
+ context_medium_threshold=int(args.context_medium_threshold),
4637
+ cctally_extensions=bool(ext_on),
4638
+ color=bool(color),
4639
+ display_tz_name=tz_name,
4640
+ debug=bool(args.debug),
4641
+ )
4642
+
4643
+ # Build injections (DB + transcript file IO).
4644
+ inj = _build_statusline_injections(warn_once)
4645
+
4646
+ # `_command_as_of()` honors the `CCTALLY_AS_OF` testing hook so the
4647
+ # golden harness can pin "now" for deterministic block-remaining and
4648
+ # 5h/7d countdown numbers. Falls back to wall-clock UTC otherwise.
4649
+ now = _command_as_of()
4650
+ try:
4651
+ line = _lib_statusline.render_statusline(inp, sargs, inj, now)
4652
+ except Exception as exc: # pragma: no cover — defensive
4653
+ eprint(f"cctally statusline: render failed: {exc}")
4654
+ return 1
4655
+ print(line)
4656
+ return 0
4657
+
4658
+
4659
+ def _resolve_context_window(model_id, warn_once) -> "int | None":
4660
+ """Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
4661
+ to a family-substring match against
4662
+ ``CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY``. Unknown id → ``None`` +
4663
+ one-shot stderr warning.
4664
+ """
4665
+ if not model_id:
4666
+ return None
4667
+ if model_id in CLAUDE_MODEL_CONTEXT_WINDOWS:
4668
+ return CLAUDE_MODEL_CONTEXT_WINDOWS[model_id]
4669
+ mid_lower = model_id.lower()
4670
+ for family, window in CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY.items():
4671
+ if family in mid_lower:
4672
+ return window
4673
+ warn_once(
4674
+ f"cctally statusline: unknown model {model_id!r}; context % unavailable"
4675
+ )
4676
+ return None
4677
+
4678
+
4679
+ def _read_last_assistant_usage(transcript_path):
4680
+ """Tail-walk the transcript JSONL backwards to the most recent
4681
+ ``type=assistant`` line carrying ``message.usage``. Returns the usage
4682
+ dict or ``None``.
4683
+
4684
+ Reads in 64 KB chunks from the end so multi-MB transcripts don't
4685
+ block the hot statusline path with a full-file parse.
4686
+ """
4687
+ if not transcript_path:
4688
+ return None
4689
+ path = pathlib.Path(transcript_path)
4690
+ if not path.exists():
4691
+ return None
4692
+ try:
4693
+ with path.open("rb") as fh:
4694
+ fh.seek(0, 2)
4695
+ size = fh.tell()
4696
+ tail = b""
4697
+ chunk = 65536
4698
+ # Read backwards in chunks until we have at least one full line
4699
+ # and the tail starts with a newline (so the first line is whole).
4700
+ while size > 0 and tail.count(b"\n") < 2:
4701
+ read_at = max(0, size - chunk)
4702
+ fh.seek(read_at)
4703
+ tail = fh.read(size - read_at) + tail
4704
+ size = read_at
4705
+ lines = tail.split(b"\n")
4706
+ except OSError:
4707
+ return None
4708
+ for line in reversed(lines):
4709
+ line = line.strip()
4710
+ if not line:
4711
+ continue
4712
+ try:
4713
+ obj = json.loads(line)
4714
+ except Exception:
4715
+ continue
4716
+ if not isinstance(obj, dict):
4717
+ continue
4718
+ if obj.get("type") != "assistant":
4719
+ continue
4720
+ msg = obj.get("message") or {}
4721
+ usage = msg.get("usage") if isinstance(msg, dict) else None
4722
+ if isinstance(usage, dict):
4723
+ return usage
4724
+ return None
4725
+
4726
+
4727
+ def _build_statusline_injections(warn_once):
4728
+ """Wire DB- and FS-backed implementations for the kernel's injection ports.
4729
+
4730
+ See ``_lib_statusline.StatuslineInjections`` for the contract. All
4731
+ callables fast-fail to "no data" on any exception — statusline must
4732
+ NEVER block the Claude Code hook tick.
4733
+ """
4734
+ def _cctally_session_cost(sid):
4735
+ if not sid:
4736
+ return None
4737
+ try:
4738
+ conn = open_cache_db()
4739
+ except Exception:
4740
+ return None
4741
+ try:
4742
+ # Walk all entries via session_files join; sum costs whose
4743
+ # session_id matches. Stays read-only — does NOT call
4744
+ # sync_cache (too heavy for the hot statusline path; the
4745
+ # record-usage + hook-tick paths keep the cache warm).
4746
+ sql = (
4747
+ "SELECT se.timestamp_utc, se.model, "
4748
+ " se.input_tokens, se.output_tokens, "
4749
+ " se.cache_create_tokens, se.cache_read_tokens, "
4750
+ " se.cost_usd_raw, se.usage_extra_json "
4751
+ "FROM session_entries se "
4752
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
4753
+ "WHERE sf.session_id = ?"
4754
+ )
4755
+ rows = list(conn.execute(sql, (sid,)))
4756
+ except Exception:
4757
+ return None
4758
+ finally:
4759
+ try:
4760
+ conn.close()
4761
+ except Exception:
4762
+ pass
4763
+ if not rows:
4764
+ return None
4765
+ total = 0.0
4766
+ for r in rows:
4767
+ usage = {
4768
+ "input_tokens": r[2] or 0,
4769
+ "output_tokens": r[3] or 0,
4770
+ "cache_creation_input_tokens": r[4] or 0,
4771
+ "cache_read_input_tokens": r[5] or 0,
4772
+ }
4773
+ try:
4774
+ if r[7]:
4775
+ extras = json.loads(r[7])
4776
+ if isinstance(extras, dict):
4777
+ usage.update(extras)
4778
+ except Exception:
4779
+ pass
4780
+ try:
4781
+ total += _calculate_entry_cost(
4782
+ r[1], usage, mode="auto", cost_usd=r[6],
4783
+ )
4784
+ except Exception:
4785
+ continue
4786
+ return total
4787
+
4788
+ def _today_cost(tz_name, now):
4789
+ try:
4790
+ tz = ZoneInfo(tz_name) if tz_name and tz_name != "UTC" else dt.timezone.utc
4791
+ except Exception:
4792
+ tz = dt.timezone.utc
4793
+ local_now = now.astimezone(tz)
4794
+ day_start_local = local_now.replace(
4795
+ hour=0, minute=0, second=0, microsecond=0
4796
+ )
4797
+ day_end_local = day_start_local + dt.timedelta(days=1)
4798
+ range_start = day_start_local.astimezone(dt.timezone.utc)
4799
+ range_end = day_end_local.astimezone(dt.timezone.utc)
4800
+ # Two-filter pattern (UTC half-open + display-tz date check):
4801
+ # the UTC range fetches the candidate window cheaply via the
4802
+ # cache's indexed `timestamp` column; the display-tz date check
4803
+ # then trims any entries that fall outside today's local
4804
+ # calendar day (the UTC window straddles two local dates when
4805
+ # the display tz has any UTC offset, so the SQL range alone is
4806
+ # slightly wider than the local day).
4807
+ try:
4808
+ entries = get_entries(range_start, range_end, skip_sync=True)
4809
+ except Exception:
4810
+ return 0.0
4811
+ total = 0.0
4812
+ for e in entries:
4813
+ try:
4814
+ # Filter to today in display tz (second-pass trim).
4815
+ ts_local = e.timestamp.astimezone(tz)
4816
+ if ts_local.date() != local_now.date():
4817
+ continue
4818
+ total += _calculate_entry_cost(
4819
+ e.model, e.usage, mode="auto", cost_usd=e.cost_usd,
4820
+ )
4821
+ except Exception:
4822
+ continue
4823
+ return total
4824
+
4825
+ def _active_block(now):
4826
+ try:
4827
+ # Look at last 24h — captures the full active 5h window.
4828
+ range_start = now - dt.timedelta(hours=24)
4829
+ entries = get_entries(range_start, now, skip_sync=True)
4830
+ except Exception:
4831
+ return None
4832
+ if not entries:
4833
+ return None
4834
+ try:
4835
+ recorded_windows, block_start_overrides, canonical_intervals = (
4836
+ _load_recorded_five_hour_windows(
4837
+ range_start - BLOCK_DURATION, now + BLOCK_DURATION,
4838
+ )
4839
+ )
4840
+ except Exception:
4841
+ recorded_windows, block_start_overrides, canonical_intervals = (
4842
+ [], {}, {},
4843
+ )
4844
+ try:
4845
+ blocks = _group_entries_into_blocks(
4846
+ entries,
4847
+ mode="auto",
4848
+ recorded_windows=recorded_windows,
4849
+ block_start_overrides=block_start_overrides,
4850
+ canonical_intervals=canonical_intervals,
4851
+ now=now,
4852
+ )
4853
+ except Exception:
4854
+ return None
4855
+ for b in blocks:
4856
+ if not b.is_gap and b.is_active:
4857
+ remaining_s = int((b.end_time - now).total_seconds())
4858
+ elapsed_s = int((now - b.start_time).total_seconds())
4859
+ return (float(b.cost_usd or 0.0), remaining_s, elapsed_s)
4860
+ return None
4861
+
4862
+ def _hwm_clamp(five_resets, seven_resets):
4863
+ five_hwm = None
4864
+ seven_hwm = None
4865
+ try:
4866
+ conn = open_db()
4867
+ except Exception:
4868
+ return (None, None)
4869
+ try:
4870
+ if five_resets is not None:
4871
+ try:
4872
+ key = _canonical_5h_window_key(int(five_resets))
4873
+ row = conn.execute(
4874
+ "SELECT MAX(five_hour_percent) "
4875
+ "FROM weekly_usage_snapshots "
4876
+ "WHERE five_hour_window_key = ?",
4877
+ (key,),
4878
+ ).fetchone()
4879
+ if row and row[0] is not None:
4880
+ five_hwm = float(row[0])
4881
+ except Exception:
4882
+ pass
4883
+ if seven_resets is not None:
4884
+ try:
4885
+ # Seven-day window: derive week_start_date from the
4886
+ # resets_at epoch (reset - 7 days, UTC date).
4887
+ week_start_date = dt.datetime.fromtimestamp(
4888
+ int(seven_resets) - 7 * 86400, tz=dt.timezone.utc,
4889
+ ).date().isoformat()
4890
+ row = conn.execute(
4891
+ "SELECT MAX(weekly_percent) "
4892
+ "FROM weekly_usage_snapshots "
4893
+ "WHERE week_start_date = ?",
4894
+ (week_start_date,),
4895
+ ).fetchone()
4896
+ if row and row[0] is not None:
4897
+ seven_hwm = float(row[0])
4898
+ except Exception:
4899
+ pass
4900
+ finally:
4901
+ try:
4902
+ conn.close()
4903
+ except Exception:
4904
+ pass
4905
+ return (five_hwm, seven_hwm)
4906
+
4907
+ def _db_latest_rate_limits():
4908
+ try:
4909
+ conn = open_db()
4910
+ except Exception:
4911
+ return None
4912
+ try:
4913
+ # Prefer `week_end_at` (ISO timestamp; sub-day precision) over
4914
+ # `week_end_date` (date-only; UTC-midnight). Older snapshots
4915
+ # may have `week_end_at` NULL — fall back to the date column
4916
+ # in that case. See the neighbor query in `pick_week_selection`
4917
+ # (bin/cctally:3849) for the precedent.
4918
+ row = conn.execute(
4919
+ "SELECT five_hour_percent, five_hour_window_key, "
4920
+ " weekly_percent, week_end_at, week_end_date "
4921
+ "FROM weekly_usage_snapshots "
4922
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
4923
+ ).fetchone()
4924
+ if not row:
4925
+ return None
4926
+ five_pct = float(row[0]) if row[0] is not None else None
4927
+ five_resets = int(row[1]) if row[1] is not None else None
4928
+ seven_pct = float(row[2]) if row[2] is not None else None
4929
+ seven_resets = None
4930
+ week_end_at = row[3]
4931
+ week_end_date = row[4]
4932
+ if week_end_at:
4933
+ try:
4934
+ # `datetime.fromisoformat` accepts the trailing `Z`
4935
+ # only on Python 3.11+; normalize to `+00:00` so 3.10
4936
+ # checkouts (and any odd Z-suffixed snapshot) parse.
4937
+ raw_iso = str(week_end_at)
4938
+ if raw_iso.endswith("Z"):
4939
+ raw_iso = raw_iso[:-1] + "+00:00"
4940
+ end_dt = dt.datetime.fromisoformat(raw_iso)
4941
+ if end_dt.tzinfo is None:
4942
+ end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
4943
+ seven_resets = int(end_dt.timestamp())
4944
+ except Exception:
4945
+ seven_resets = None
4946
+ if seven_resets is None and week_end_date:
4947
+ try:
4948
+ end_dt = dt.datetime.fromisoformat(str(week_end_date))
4949
+ if end_dt.tzinfo is None:
4950
+ end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
4951
+ # week_end_date is exclusive — that's the reset moment.
4952
+ seven_resets = int(end_dt.timestamp())
4953
+ except Exception:
4954
+ seven_resets = None
4955
+ return (five_pct, five_resets, seven_pct, seven_resets)
4956
+ except Exception:
4957
+ return None
4958
+ finally:
4959
+ try:
4960
+ conn.close()
4961
+ except Exception:
4962
+ pass
4963
+
4964
+ def _context_pct(transcript_path, model_id):
4965
+ if not transcript_path or not model_id:
4966
+ return None
4967
+ window = _resolve_context_window(model_id, warn_once)
4968
+ if window is None:
4969
+ return None
4970
+ try:
4971
+ usage = _read_last_assistant_usage(transcript_path)
4972
+ except Exception:
4973
+ return None
4974
+ if not isinstance(usage, dict):
4975
+ return None
4976
+ try:
4977
+ ctx_tokens = (
4978
+ int(usage.get("input_tokens", 0) or 0)
4979
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
4980
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
4981
+ )
4982
+ except (TypeError, ValueError):
4983
+ return None
4984
+ if window <= 0:
4985
+ return None
4986
+ return ctx_tokens / window * 100.0
4987
+
4988
+ return _lib_statusline.StatuslineInjections(
4989
+ cctally_session_cost=_cctally_session_cost,
4990
+ today_cost=_today_cost,
4991
+ active_block=_active_block,
4992
+ hwm_clamp=_hwm_clamp,
4993
+ db_latest_rate_limits=_db_latest_rate_limits,
4994
+ context_pct=_context_pct,
4995
+ warn_once=warn_once,
4996
+ )
4997
+
4998
+
4339
4999
  def _maybe_swap_active_block_to_canonical(
4340
5000
  blocks: list[Any],
4341
5001
  all_entries: list[Any],
@@ -4971,25 +5631,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4971
5631
 
4972
5632
 
4973
5633
  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).
5634
+ """True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
5635
+
5636
+ Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
5637
+ separated $CODEX_HOME, else ~/.codex) including direct-JSONL entries,
5638
+ which usually have no config.toml (read → absent → skipped) but DO count
5639
+ if one is present. Returns on the first root that requests it (any-root
5640
+ semantics, matching upstream ccusage). Tolerates absent/unreadable config
5641
+ (→ that root contributes nothing).
4979
5642
  """
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)
5643
+ for root in _codex_home_roots():
5644
+ cfg = root / "config.toml"
5645
+ try:
5646
+ content = cfg.read_text(encoding="utf-8", errors="replace")
5647
+ except OSError:
5648
+ continue
5649
+ if _codex_config_requests_fast_service_tier(content):
5650
+ return True
5651
+ return False
4986
5652
 
4987
5653
 
4988
5654
  def _resolve_codex_speed(requested: str) -> str:
4989
5655
  """Resolve a ``--speed`` value to an effective tier.
4990
5656
 
4991
- ``auto`` → ``fast`` iff ``~/.codex/config.toml`` requests it, else
4992
- ``standard``. ``fast``/``standard`` pass through unchanged.
5657
+ ``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
5658
+ requests it, else ``standard``. ``fast``/``standard`` pass through
5659
+ unchanged.
4993
5660
  """
4994
5661
  if requested == "auto":
4995
5662
  return "fast" if _detect_codex_fast_service_tier() else "standard"
@@ -9830,11 +10497,16 @@ def doctor_gather_state(
9830
10497
  except Exception:
9831
10498
  pass
9832
10499
 
10500
+ # Issue #109: probe every $CODEX_HOME session root (not the single
10501
+ # hardcoded ~/.codex/sessions), matching the multi-root ingestion path
10502
+ # from #108. _codex_session_roots() already applies the sessions/-subdir
10503
+ # rule and filters to existing dirs, so a bare glob per root suffices.
9833
10504
  codex_jsonl_present = False
9834
10505
  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
10506
+ for codex_dir in _codex_session_roots():
10507
+ if next(codex_dir.glob("**/*.jsonl"), None) is not None:
10508
+ codex_jsonl_present = True
10509
+ break
9838
10510
  except Exception:
9839
10511
  pass
9840
10512
 
@@ -10601,12 +11273,213 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
10601
11273
  help="Display timezone: local, utc, or IANA name. "
10602
11274
  "Overrides config display.tz for this call.",
10603
11275
  )
11276
+ p.add_argument(
11277
+ "-a", "--active", action="store_true",
11278
+ help="Show only the active block, with burn-rate + projection "
11279
+ "(ccusage drop-in).",
11280
+ )
11281
+ p.add_argument(
11282
+ "-r", "--recent", action="store_true",
11283
+ help="Show only blocks from the last 3 days (plus the active block).",
11284
+ )
11285
+ p.add_argument(
11286
+ "-t", "--token-limit", dest="token_limit", default=None,
11287
+ metavar="N|max",
11288
+ help="Token limit for the quota %% column / projection warnings. "
11289
+ "An integer, or 'max' (default) to derive from the largest "
11290
+ "completed block.",
11291
+ )
11292
+ p.add_argument(
11293
+ "-n", "--session-length", dest="session_length", type=float,
11294
+ default=5.0, metavar="N",
11295
+ help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
11296
+ "follow Anthropic's real 5-hour resets and are not re-sizable. "
11297
+ "A value <= 0 is rejected.",
11298
+ )
10604
11299
  _add_ccusage_alias_args(p, ansi_emit=False)
10605
11300
  _add_mode_arg(p)
10606
11301
  p.set_defaults(func=cmd_blocks)
10607
11302
  return p
10608
11303
 
10609
11304
 
11305
+ def _build_statusline_parser(subparsers, name, *, help_text, xref):
11306
+ """Build the `statusline` (or `claude statusline`) leaf parser.
11307
+
11308
+ Registered TWICE per the Session B build-once register-twice pattern:
11309
+ once on the flat ``cctally statusline`` subparser, once under the
11310
+ nested ``cctally claude statusline`` subgroup. Output is byte-identical
11311
+ between the two forms; only ``--help`` text differs (the ``xref``
11312
+ paragraph appended to ``description``).
11313
+ """
11314
+ p = subparsers.add_parser(
11315
+ name,
11316
+ help=help_text,
11317
+ formatter_class=CLIHelpFormatter,
11318
+ description=(
11319
+ "Display a compact one-line status for Claude Code hooks "
11320
+ "(ccusage drop-in + cctally extensions).\n\n" + xref
11321
+ ),
11322
+ )
11323
+ # ccusage-shape flags
11324
+ p.add_argument(
11325
+ "-B", "--visual-burn-rate",
11326
+ dest="visual_burn_rate",
11327
+ default=None,
11328
+ choices=["off", "emoji", "text", "emoji-text"],
11329
+ help="Burn-rate visualization (default: off; config key "
11330
+ "statusline.visual_burn_rate).",
11331
+ )
11332
+ # NOTE: `ccusage` is intentionally NOT in `choices=` so it doesn't
11333
+ # appear in `--help` advertised options. Argparse runs the `choices`
11334
+ # check BEFORE the action's `__call__`, so we cannot list `ccusage`
11335
+ # in `choices=` AND catch it in the action. Instead we omit `choices=`
11336
+ # entirely, manually validate inside the action, and re-raise the
11337
+ # spec's rename hint via `parser.error` for `ccusage` specifically.
11338
+ # Help text below hardcodes the legal set so users still see it in
11339
+ # `--help`. A typo like `ccussage` falls through to a standard
11340
+ # argparse-style "invalid choice" error from `parser.error`.
11341
+ class _CostSourceAction(argparse.Action):
11342
+ _ACCEPTED = ("auto", "cctally", "cc", "both")
11343
+ _RENAMED = "ccusage"
11344
+
11345
+ def __call__(self, parser, namespace, values, option_string=None):
11346
+ if values == self._RENAMED:
11347
+ parser.error(
11348
+ f"argument {option_string}: invalid choice: "
11349
+ f"{values!r} — cctally renamed it; try "
11350
+ f"--cost-source cctally"
11351
+ )
11352
+ if values not in self._ACCEPTED:
11353
+ parser.error(
11354
+ f"argument {option_string}: invalid choice: "
11355
+ f"{values!r} (choose from "
11356
+ + ", ".join(repr(c) for c in self._ACCEPTED)
11357
+ + ")"
11358
+ )
11359
+ setattr(namespace, self.dest, values)
11360
+
11361
+ p.add_argument(
11362
+ "--cost-source",
11363
+ dest="cost_source",
11364
+ default=None,
11365
+ action=_CostSourceAction,
11366
+ metavar="{auto,cctally,cc,both}",
11367
+ help="Session cost source (default: auto; config key "
11368
+ "statusline.cost_source). Note: 'ccusage' errors with a "
11369
+ "rename hint — use 'cctally' instead.",
11370
+ )
11371
+ p.add_argument(
11372
+ "--cache",
11373
+ dest="cache",
11374
+ action="store_true",
11375
+ default=None,
11376
+ help="Accepted for ccusage drop-in compat; cctally renders from "
11377
+ "cache.db directly without an extra output cache.",
11378
+ )
11379
+ p.add_argument(
11380
+ "--no-cache",
11381
+ dest="cache",
11382
+ action="store_false",
11383
+ help="(no-op alias)",
11384
+ )
11385
+ p.add_argument(
11386
+ "--refresh-interval",
11387
+ dest="refresh_interval",
11388
+ default=1,
11389
+ type=int,
11390
+ metavar="N",
11391
+ help="(no-op alias) Accepted for ccusage drop-in compat.",
11392
+ )
11393
+ p.add_argument(
11394
+ "--context-low-threshold",
11395
+ dest="context_low_threshold",
11396
+ default=50,
11397
+ type=int,
11398
+ metavar="N",
11399
+ help="Below this %% → segment 4 green (default: 50, 0-100).",
11400
+ )
11401
+ p.add_argument(
11402
+ "--context-medium-threshold",
11403
+ dest="context_medium_threshold",
11404
+ default=80,
11405
+ type=int,
11406
+ metavar="N",
11407
+ help="Below this %% → segment 4 yellow; else red (default: 80, 0-100).",
11408
+ )
11409
+ p.add_argument(
11410
+ "-z", "--timezone",
11411
+ dest="timezone",
11412
+ default=None,
11413
+ metavar="TZ",
11414
+ help="Display tz (IANA) for `today` calendar day. Overrides "
11415
+ "display.tz config.",
11416
+ )
11417
+ p.add_argument(
11418
+ "-O", "--offline",
11419
+ dest="offline",
11420
+ action="store_true",
11421
+ default=True,
11422
+ help="(no-op alias) cctally is always offline.",
11423
+ )
11424
+ p.add_argument(
11425
+ "--no-offline",
11426
+ dest="offline",
11427
+ action="store_false",
11428
+ help="(no-op alias)",
11429
+ )
11430
+ p.add_argument(
11431
+ "--color",
11432
+ dest="color",
11433
+ action="store_true",
11434
+ default=None,
11435
+ help="Force ANSI colors on (default: auto via NO_COLOR + TTY).",
11436
+ )
11437
+ p.add_argument(
11438
+ "--no-color",
11439
+ dest="color",
11440
+ action="store_false",
11441
+ help="Force ANSI colors off.",
11442
+ )
11443
+ p.add_argument(
11444
+ "--cctally-extensions",
11445
+ dest="cctally_extensions",
11446
+ action="store_true",
11447
+ default=None,
11448
+ help="Append cctally 5h%%/7d%% segment (default: on; config key "
11449
+ "statusline.cctally_extensions).",
11450
+ )
11451
+ p.add_argument(
11452
+ "--no-cctally-extensions",
11453
+ dest="cctally_extensions",
11454
+ action="store_false",
11455
+ help="Suppress cctally 5h%%/7d%% segment.",
11456
+ )
11457
+ p.add_argument(
11458
+ "--config",
11459
+ dest="config",
11460
+ default=None,
11461
+ metavar="PATH",
11462
+ help="Read config from PATH for this invocation only (no "
11463
+ "mutation of the default config). Missing/invalid PATH "
11464
+ "exits 2.",
11465
+ )
11466
+ p.add_argument(
11467
+ "--single-thread",
11468
+ dest="single_thread",
11469
+ action="store_true",
11470
+ help="(no-op alias) cctally is always single-threaded via the "
11471
+ "session-entry cache.",
11472
+ )
11473
+ p.add_argument(
11474
+ "-d", "--debug",
11475
+ dest="debug",
11476
+ action="store_true",
11477
+ help="Emit pricing-mismatch / config diagnostics on stderr.",
11478
+ )
11479
+ p.set_defaults(func=cmd_statusline, command=name)
11480
+ return p
11481
+
11482
+
10610
11483
  def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
10611
11484
  """Build the `codex-daily` leaf parser (issue #86 Session B; routes to cmd_codex_daily)."""
10612
11485
  p = subparsers.add_parser(
@@ -11467,6 +12340,12 @@ def build_parser() -> argparse.ArgumentParser:
11467
12340
  help_text="Show usage report grouped by 5-hour session blocks",
11468
12341
  xref="Alias of `cctally claude blocks` (the canonical form).")
11469
12342
 
12343
+ # -- statusline --
12344
+ _build_statusline_parser(
12345
+ sub, "statusline",
12346
+ help_text="Compact one-line status for Claude Code hooks",
12347
+ xref="Alias of `cctally claude statusline` (the canonical form).")
12348
+
11470
12349
  # -- five-hour-blocks --
11471
12350
  fhb = sub.add_parser(
11472
12351
  "five-hour-blocks",
@@ -11722,6 +12601,10 @@ def build_parser() -> argparse.ArgumentParser:
11722
12601
  _build_blocks_parser(claude_sub, "blocks",
11723
12602
  help_text="Show usage grouped by 5-hour session blocks",
11724
12603
  xref="Drop-in for `ccusage claude blocks`. Same engine as `cctally blocks`.")
12604
+ _build_statusline_parser(claude_sub, "statusline",
12605
+ help_text="Compact one-line status for Claude Code hooks",
12606
+ xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
12607
+ "Drop-in for `ccusage statusline` plus cctally extension segments.")
11725
12608
 
11726
12609
  # --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
11727
12610
  codex_p = sub.add_parser(