cctally 1.69.3 → 1.70.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 +12 -0
- package/bin/_cctally_core.py +26 -2
- package/bin/_cctally_doctor.py +10 -0
- package/bin/_cctally_setup.py +320 -0
- package/bin/_cctally_statusline.py +11 -0
- package/bin/_lib_doctor.py +48 -0
- package/bin/_lib_statusline.py +26 -0
- package/bin/cctally +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.70.0] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally setup` now adds `statusLine.refreshInterval: 30` to a cctally-pointing Claude Code `statusLine` block that lacks one, so status-line-fed usage keeps recording on a 30-second timer while a coordinator session waits on a long-running subagent (Claude Code's event-driven status-line updates go quiet then). Ownership is add-when-absent / never-mutate / never-remove: setup never creates a `statusLine` block, never changes a `refreshInterval` you set yourself, and `--uninstall` leaves it. Surfaced in install / `--status` / `--dry-run` (text plus the `--json` `statusline_refresh {state, value, action}` object). (#311)
|
|
12
|
+
- `cctally doctor` gains a `hooks.statusline_refresh_interval` check that WARNs when a recognized cctally `statusLine` command is missing its `refreshInterval`, and is OK (with a per-state summary) otherwise. (#311)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Retuned the status-line usage-persist throttle from 60s to 25s so it sits below the new 30s `statusLine.refreshInterval` timer; a 60/60 pairing beat-frequency-throttled every other tick and oscillated the effective cadence between 60 and 120 seconds. (#311)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Status-line usage persistence now skips sessions running a bracketed model variant such as `claude-opus-4-8[1m]` (the 1M-context variant), whose `rate_limits` describe a separate account usage pool. Persisting those poisoned the default-pool snapshots — the reset-aware high-water-mark clamp latched the foreign value and froze all subsequent genuine writes. The guard is persist-only, so a `[1m]` session still renders its own true pool numbers on the status line. (#311)
|
|
19
|
+
|
|
8
20
|
## [1.69.3] - 2026-07-17
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/bin/_cctally_core.py
CHANGED
|
@@ -243,13 +243,37 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
243
243
|
#
|
|
244
244
|
# STATUSLINE_PERSIST_THROTTLE_SECONDS: min seconds between statusline
|
|
245
245
|
# persist attempts (keyed off STATUSLINE_OBSERVE_MARKER_PATH liveness).
|
|
246
|
+
# Retuned 60.0 -> 25.0 for #311: the setup-managed statusLine.refreshInterval
|
|
247
|
+
# timer fires the statusline on a 30s cadence, and the throttle MUST be
|
|
248
|
+
# strictly LESS than that interval. When interval <= throttle, phase jitter
|
|
249
|
+
# produces beat-frequency skips (a tick at 59.9s of marker age is throttled;
|
|
250
|
+
# the next persist waits ~120s), so a 60/60 pairing oscillates 60<->120s.
|
|
251
|
+
# With interval 30 > throttle 25, every tick whose predecessor's record
|
|
252
|
+
# completed promptly (within interval - throttle = 5s) passes the gate.
|
|
253
|
+
# CADENCE QUALIFIER: the marker is touched at persist COMPLETION, so a tick
|
|
254
|
+
# observes marker age = 30 - d where d is the previous record's duration;
|
|
255
|
+
# if d > 5s (the record kernel can run cmd_sync_week JSONL scans) that tick
|
|
256
|
+
# throttles and the cycle degrades to skip-one-tick (~60s), self-correcting
|
|
257
|
+
# on the next tick. Exact-30s is NOT an invariant (no attempt-start marker
|
|
258
|
+
# exists — one would let a hung/failed record claim liveness and suppress
|
|
259
|
+
# the OAuth backfill). Still strictly better than the 60/60 beat's 60-120s.
|
|
246
260
|
# OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
|
|
247
261
|
# observation marker is at least this stale (i.e. the statusline has
|
|
248
262
|
# NOT fed recently). Strictly greater than the persist throttle so the
|
|
249
|
-
# statusline is the primary writer and OAuth only covers its absence
|
|
263
|
+
# statusline is the primary writer and OAuth only covers its absence
|
|
264
|
+
# (300 > 25 still holds).
|
|
250
265
|
# OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
|
|
251
266
|
# exponential 429 backoff (base * 2**consecutive_429, capped).
|
|
252
|
-
STATUSLINE_PERSIST_THROTTLE_SECONDS =
|
|
267
|
+
STATUSLINE_PERSIST_THROTTLE_SECONDS = 25.0
|
|
268
|
+
# STATUSLINE_REFRESH_INTERVAL_DEFAULT (#311): the value `cctally setup`
|
|
269
|
+
# writes into Claude Code's settings.json `statusLine.refreshInterval` when a
|
|
270
|
+
# recognized cctally statusLine block lacks one. Claude Code re-runs the
|
|
271
|
+
# statusline command on this fixed timer "in addition to the event-driven
|
|
272
|
+
# updates", which keeps the usage-persistence feeder ticking while a parent
|
|
273
|
+
# session waits on a long subagent (event-driven updates go quiet then). MUST
|
|
274
|
+
# exceed STATUSLINE_PERSIST_THROTTLE_SECONDS (30 > 25) — see the pairing rule
|
|
275
|
+
# above. Add-when-absent only; a user-set value is never mutated.
|
|
276
|
+
STATUSLINE_REFRESH_INTERVAL_DEFAULT = 30
|
|
253
277
|
OAUTH_BACKFILL_STALE_SECONDS = 300.0
|
|
254
278
|
OAUTH_BACKOFF_BASE_SECONDS = 60.0
|
|
255
279
|
OAUTH_BACKOFF_CAP_SECONDS = 3600.0
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -187,6 +187,14 @@ def doctor_gather_state(
|
|
|
187
187
|
settings = c._load_claude_settings()
|
|
188
188
|
except c.SetupError:
|
|
189
189
|
settings = None
|
|
190
|
+
# #311: precompute the statusLine.refreshInterval state via the setup
|
|
191
|
+
# I/O-layer classifier (wrapper recognition does file scans), so the pure
|
|
192
|
+
# doctor kernel stays I/O-free. `settings is None` (SetupError) → the
|
|
193
|
+
# classifier's `unavailable`, matching the check's always-OK posture.
|
|
194
|
+
try:
|
|
195
|
+
statusline_refresh_state = c._classify_statusline_refresh(settings)[0]
|
|
196
|
+
except Exception:
|
|
197
|
+
statusline_refresh_state = "unavailable"
|
|
190
198
|
# Below: fail-soft posture for the diagnostic — any unexpected error
|
|
191
199
|
# in a sub-probe degrades that field to None rather than aborting the
|
|
192
200
|
# whole report.
|
|
@@ -777,6 +785,8 @@ def doctor_gather_state(
|
|
|
777
785
|
codex_quota_windows=codex_quota_windows,
|
|
778
786
|
codex_hook_roots=codex_hook_roots,
|
|
779
787
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
788
|
+
# #311: precomputed statusLine.refreshInterval classification.
|
|
789
|
+
statusline_refresh_state=statusline_refresh_state,
|
|
780
790
|
)
|
|
781
791
|
|
|
782
792
|
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -62,6 +62,7 @@ import datetime as dt
|
|
|
62
62
|
import json
|
|
63
63
|
import os
|
|
64
64
|
import pathlib
|
|
65
|
+
import re
|
|
65
66
|
import shutil
|
|
66
67
|
import subprocess
|
|
67
68
|
import sys
|
|
@@ -404,6 +405,246 @@ def _settings_merge_unwire_legacy(settings: dict) -> tuple[dict, int]:
|
|
|
404
405
|
return settings, removed
|
|
405
406
|
|
|
406
407
|
|
|
408
|
+
# ── statusLine.refreshInterval recognizer + classifier + merge (#311 D3) ─
|
|
409
|
+
# The setup-managed statusLine.refreshInterval keeps the usage-persistence
|
|
410
|
+
# feeder ticking while a parent session waits on a long subagent (Claude
|
|
411
|
+
# Code's event-driven statusline updates go quiet then). Ownership is
|
|
412
|
+
# add-when-absent / never-mutate / never-remove: we only augment a statusLine
|
|
413
|
+
# block that already points at cctally, never create one, never change a
|
|
414
|
+
# user's value, and never delete it. These are I/O-LAYER helpers (wrapper
|
|
415
|
+
# recognition does path expansion, existence checks, and file-content
|
|
416
|
+
# scanning, so purity is impossible — Codex R2 F4); they live here and MUST
|
|
417
|
+
# NOT be imported by bin/_lib_doctor.py (the doctor kernel stays I/O-free —
|
|
418
|
+
# doctor_gather_state calls these and passes only the resulting state string
|
|
419
|
+
# into DoctorState).
|
|
420
|
+
|
|
421
|
+
_STATUSLINE_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
422
|
+
_STATUSLINE_ASSIGN_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
423
|
+
_STATUSLINE_VAR_REF_RE = re.compile(r"^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$")
|
|
424
|
+
_STATUSLINE_SHELLS = ("sh", "bash", "zsh")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _statusline_executable_kind(token: str) -> "str | None":
|
|
428
|
+
"""Classify a bare executable token.
|
|
429
|
+
|
|
430
|
+
``"sub"`` when the basename is ``cctally`` or the npm shim (a
|
|
431
|
+
``statusline`` subcommand must follow), ``"self"`` when it is
|
|
432
|
+
``cctally-statusline`` (self-contained — ``bin/cctally-statusline`` itself
|
|
433
|
+
dispatches to ``cctally statusline``, so no following subcommand is
|
|
434
|
+
required, Codex R3 F1), else ``None``."""
|
|
435
|
+
if not isinstance(token, str):
|
|
436
|
+
return None
|
|
437
|
+
name = pathlib.PurePosixPath(token).name
|
|
438
|
+
if name in ("cctally", _CCTALLY_NPM_SHIM_BASENAME):
|
|
439
|
+
return "sub"
|
|
440
|
+
if name == "cctally-statusline":
|
|
441
|
+
return "self"
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _statusline_tokens_have_direct_invocation(tokens: list) -> bool:
|
|
446
|
+
"""True iff ``tokens`` (one script line) carry a CORRELATED cctally
|
|
447
|
+
statusline invocation: a cctally/shim token IMMEDIATELY followed by
|
|
448
|
+
``statusline`` (or ``claude statusline``), or a self-contained
|
|
449
|
+
``cctally-statusline`` token. Adjacency (not independent needles) defeats
|
|
450
|
+
the `cctally forecast` + foreign `ccusage statusline` false positive
|
|
451
|
+
(Codex R2 F1); scanning every position (not just the head) catches the
|
|
452
|
+
common piped/`exec`-prefixed real-wrapper shapes. Known accepted
|
|
453
|
+
residual: a literal token pair anywhere on the line (e.g. an unquoted
|
|
454
|
+
`echo cctally statusline`) matches too — bounded by the
|
|
455
|
+
LEGACY_STATUSLINE_PATHS anchor and a benign add-when-absent blast
|
|
456
|
+
radius; head-anchoring would false-negative the real piped wrapper."""
|
|
457
|
+
for i, tok in enumerate(tokens):
|
|
458
|
+
kind = _statusline_executable_kind(tok)
|
|
459
|
+
if kind == "self":
|
|
460
|
+
return True
|
|
461
|
+
if kind == "sub":
|
|
462
|
+
if tokens[i + 1:i + 2] == ["statusline"] or \
|
|
463
|
+
tokens[i + 1:i + 3] == ["claude", "statusline"]:
|
|
464
|
+
return True
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _statusline_deref_var(token: str) -> "str | None":
|
|
469
|
+
"""Variable name from ``$VAR`` / ``${VAR}`` (shlex already stripped any
|
|
470
|
+
surrounding quotes), else ``None``."""
|
|
471
|
+
m = _STATUSLINE_VAR_REF_RE.match(token)
|
|
472
|
+
return m.group(1) if m else None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _statusline_script_content_correlated(text: str) -> bool:
|
|
476
|
+
"""Two-pass static analysis of a legacy wrapper script's (comment-stripped)
|
|
477
|
+
content. True iff a line directly invokes ``cctally statusline`` (or a
|
|
478
|
+
self-contained ``cctally-statusline``), OR a variable is bound to a
|
|
479
|
+
recognized cctally executable and later invoked with ``statusline`` (bare
|
|
480
|
+
for the self-contained kind). Per-line full grammar is deliberately NOT
|
|
481
|
+
parsed (the indirection makes it impossible); the assignment-then-
|
|
482
|
+
invocation correlation is static-analyzable and bounded by the legacy-path
|
|
483
|
+
anchor (Codex R2 F1 / R3 F1)."""
|
|
484
|
+
import shlex
|
|
485
|
+
lines: list = []
|
|
486
|
+
for raw in text.splitlines():
|
|
487
|
+
stripped = raw.strip()
|
|
488
|
+
if not stripped or stripped.startswith("#"):
|
|
489
|
+
continue
|
|
490
|
+
try:
|
|
491
|
+
toks = shlex.split(raw)
|
|
492
|
+
except ValueError:
|
|
493
|
+
continue
|
|
494
|
+
if toks:
|
|
495
|
+
lines.append(toks)
|
|
496
|
+
|
|
497
|
+
var_kind: dict = {} # var name -> "sub" | "self"
|
|
498
|
+
for toks in lines:
|
|
499
|
+
# Pass 1a: a directly-correlated invocation on this line ends it.
|
|
500
|
+
if _statusline_tokens_have_direct_invocation(toks):
|
|
501
|
+
return True
|
|
502
|
+
# Pass 1b: record `VAR=<recognized executable>` assignments.
|
|
503
|
+
for tok in toks:
|
|
504
|
+
m = _STATUSLINE_ASSIGN_RE.match(tok)
|
|
505
|
+
if not m:
|
|
506
|
+
continue
|
|
507
|
+
kind = _statusline_executable_kind(m.group(2).strip())
|
|
508
|
+
if kind:
|
|
509
|
+
var_kind[m.group(1)] = kind
|
|
510
|
+
|
|
511
|
+
if not var_kind:
|
|
512
|
+
return False
|
|
513
|
+
# Pass 2: a later line invoking a bound variable, correlated the same way.
|
|
514
|
+
for toks in lines:
|
|
515
|
+
for i, tok in enumerate(toks):
|
|
516
|
+
var = _statusline_deref_var(tok)
|
|
517
|
+
if var is None or var not in var_kind:
|
|
518
|
+
continue
|
|
519
|
+
kind = var_kind[var]
|
|
520
|
+
if kind == "self":
|
|
521
|
+
return True
|
|
522
|
+
if kind == "sub" and (
|
|
523
|
+
toks[i + 1:i + 2] == ["statusline"]
|
|
524
|
+
or toks[i + 1:i + 3] == ["claude", "statusline"]
|
|
525
|
+
):
|
|
526
|
+
return True
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _statusline_wrapper_script_matches(script_token: str) -> bool:
|
|
531
|
+
"""True iff ``script_token`` — after ``$HOME``/``${HOME}``/``~`` expansion
|
|
532
|
+
(Codex: os.path.expanduser alone does NOT expand $HOME; expandvars first) —
|
|
533
|
+
resolves to an existing file at one of ``LEGACY_STATUSLINE_PATHS`` whose
|
|
534
|
+
comment-stripped content carries a correlated cctally-statusline
|
|
535
|
+
invocation."""
|
|
536
|
+
c = _cctally()
|
|
537
|
+
expanded = os.path.expanduser(os.path.expandvars(script_token))
|
|
538
|
+
norm = os.path.normpath(expanded)
|
|
539
|
+
legacy = c.LEGACY_STATUSLINE_PATHS
|
|
540
|
+
if not any(os.path.normpath(str(lp)) == norm for lp in legacy):
|
|
541
|
+
return False
|
|
542
|
+
p = pathlib.Path(expanded)
|
|
543
|
+
if not p.is_file():
|
|
544
|
+
return False
|
|
545
|
+
try:
|
|
546
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
547
|
+
except OSError:
|
|
548
|
+
return False
|
|
549
|
+
return _statusline_script_content_correlated(text)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _is_cctally_statusline_command(cmd) -> bool:
|
|
553
|
+
"""True iff ``cmd`` runs ``cctally statusline`` (the #311 anchored grammar).
|
|
554
|
+
|
|
555
|
+
Anchored EXECUTION grammar (Codex R1 F3 — free token scanning would
|
|
556
|
+
false-positive on ``echo cctally statusline`` / ``cat <legacy path>``):
|
|
557
|
+
``shlex.split`` the command (malformed → False), strip leading
|
|
558
|
+
``VAR=value`` env-assignment tokens, then classify on the FIRST command
|
|
559
|
+
token.
|
|
560
|
+
|
|
561
|
+
Direct form: first token is ``cctally``/npm-shim followed immediately by
|
|
562
|
+
``statusline`` (or ``claude statusline`` — the documented subgroup form),
|
|
563
|
+
or first token is ``cctally-statusline``. Trailing flags tolerated.
|
|
564
|
+
|
|
565
|
+
Wrapper form (the real-world default): first token is a shell
|
|
566
|
+
(``sh``/``bash``/``zsh``) whose first non-flag argument resolves to a
|
|
567
|
+
legacy-path script with correlated content; a bare legacy-path token with
|
|
568
|
+
no shell prefix is matched the same way."""
|
|
569
|
+
import shlex
|
|
570
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
571
|
+
return False
|
|
572
|
+
try:
|
|
573
|
+
tokens = shlex.split(cmd.strip())
|
|
574
|
+
except ValueError:
|
|
575
|
+
return False
|
|
576
|
+
idx = 0
|
|
577
|
+
while idx < len(tokens) and _STATUSLINE_ENV_ASSIGN_RE.match(tokens[idx]):
|
|
578
|
+
idx += 1
|
|
579
|
+
tokens = tokens[idx:]
|
|
580
|
+
if not tokens:
|
|
581
|
+
return False
|
|
582
|
+
first = tokens[0]
|
|
583
|
+
kind = _statusline_executable_kind(first)
|
|
584
|
+
if kind == "self":
|
|
585
|
+
return True
|
|
586
|
+
if kind == "sub":
|
|
587
|
+
rest = tokens[1:]
|
|
588
|
+
return rest[:1] == ["statusline"] or rest[:2] == ["claude", "statusline"]
|
|
589
|
+
# Not a direct cctally executable. Wrapper form.
|
|
590
|
+
if pathlib.PurePosixPath(first).name in _STATUSLINE_SHELLS:
|
|
591
|
+
script = next((t for t in tokens[1:] if not t.startswith("-")), None)
|
|
592
|
+
if script is None:
|
|
593
|
+
return False
|
|
594
|
+
return _statusline_wrapper_script_matches(script)
|
|
595
|
+
# A bare legacy-path script token (no shell prefix).
|
|
596
|
+
return _statusline_wrapper_script_matches(first)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _classify_statusline_refresh(settings) -> "tuple[str, object]":
|
|
600
|
+
"""Five-state classification of ``settings``'s
|
|
601
|
+
``statusLine.refreshInterval``, shared by setup + doctor.
|
|
602
|
+
|
|
603
|
+
States (Codex R1 F4):
|
|
604
|
+
- ``unavailable``: settings could not be loaded (None/SetupError
|
|
605
|
+
sentinel). MUST be preserved as None — coercing to ``{}`` would
|
|
606
|
+
falsely report ``absent`` ("no statusline configuration").
|
|
607
|
+
- ``absent``: no ``statusLine`` key (setup never creates one).
|
|
608
|
+
- ``foreign``: block is not a dict, ``type != "command"``, or the
|
|
609
|
+
command is not a recognized cctally statusline.
|
|
610
|
+
- ``missing``: recognized cctally statusLine with no ``refreshInterval``.
|
|
611
|
+
- ``present``: recognized + ``refreshInterval`` set.
|
|
612
|
+
|
|
613
|
+
Returns ``(state, value)`` where ``value`` is the existing
|
|
614
|
+
``refreshInterval`` echoed VERBATIM (any JSON type) for ``present``, else
|
|
615
|
+
``None`` (the settings loader validates only the root, so a user value may
|
|
616
|
+
be a string/bool/list/object — the never-mutate rule preserves all)."""
|
|
617
|
+
c = _cctally()
|
|
618
|
+
if settings is None:
|
|
619
|
+
return ("unavailable", None)
|
|
620
|
+
if not isinstance(settings, dict) or "statusLine" not in settings:
|
|
621
|
+
return ("absent", None)
|
|
622
|
+
block = settings["statusLine"]
|
|
623
|
+
if not isinstance(block, dict) or block.get("type") != "command":
|
|
624
|
+
return ("foreign", None)
|
|
625
|
+
if not c._is_cctally_statusline_command(block.get("command", "")):
|
|
626
|
+
return ("foreign", None)
|
|
627
|
+
if "refreshInterval" not in block:
|
|
628
|
+
return ("missing", None)
|
|
629
|
+
return ("present", block["refreshInterval"])
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _settings_merge_statusline_refresh_interval(settings: dict) -> bool:
|
|
633
|
+
"""Add ``statusLine.refreshInterval`` when — and ONLY when — a recognized
|
|
634
|
+
cctally statusLine block lacks it (add-when-absent / never-mutate /
|
|
635
|
+
never-remove). Returns True iff it changed anything. Called from
|
|
636
|
+
``_setup_install`` so the change rides the SAME existing atomic
|
|
637
|
+
backup+write (no second write, no new ownership state)."""
|
|
638
|
+
c = _cctally()
|
|
639
|
+
state, _ = c._classify_statusline_refresh(settings)
|
|
640
|
+
if state != "missing":
|
|
641
|
+
return False
|
|
642
|
+
settings["statusLine"]["refreshInterval"] = (
|
|
643
|
+
_cctally_core.STATUSLINE_REFRESH_INTERVAL_DEFAULT
|
|
644
|
+
)
|
|
645
|
+
return True
|
|
646
|
+
|
|
647
|
+
|
|
407
648
|
# ── symlink + path helpers ─────────────────────────────────────────────
|
|
408
649
|
|
|
409
650
|
|
|
@@ -1488,11 +1729,19 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1488
1729
|
stale_syms = list(dict.fromkeys(active_stale + retired_stale)) # union, order-stable
|
|
1489
1730
|
is_brew = _setup_is_brew_install(repo_root)
|
|
1490
1731
|
on_path = _setup_path_includes_local_bin()
|
|
1732
|
+
settings_load_failed = False
|
|
1491
1733
|
try:
|
|
1492
1734
|
settings = c._load_claude_settings()
|
|
1493
1735
|
except c.SetupError as exc:
|
|
1494
1736
|
eprint(f"setup: warning: {exc}")
|
|
1495
1737
|
settings = {}
|
|
1738
|
+
settings_load_failed = True
|
|
1739
|
+
# #311: classify statusLine.refreshInterval read-only. Preserve the
|
|
1740
|
+
# SetupError sentinel (classify from None, NOT the coerced {}) so a
|
|
1741
|
+
# malformed settings.json reports `unavailable`, never a false `absent`.
|
|
1742
|
+
sl_state, sl_value = c._classify_statusline_refresh(
|
|
1743
|
+
None if settings_load_failed else settings
|
|
1744
|
+
)
|
|
1496
1745
|
hook_counts = _setup_count_hook_entries(settings)
|
|
1497
1746
|
oauth = _setup_oauth_token_present()
|
|
1498
1747
|
throttle_age = c._hook_tick_throttle_age_seconds()
|
|
@@ -1514,6 +1763,11 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1514
1763
|
"path_includes": on_path,
|
|
1515
1764
|
},
|
|
1516
1765
|
"hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
|
|
1766
|
+
# #311: read-only statusLine.refreshInterval classification (status
|
|
1767
|
+
# never mutates → action always "none").
|
|
1768
|
+
"statusline_refresh": {
|
|
1769
|
+
"state": sl_state, "value": sl_value, "action": "none",
|
|
1770
|
+
},
|
|
1517
1771
|
"auth": {
|
|
1518
1772
|
"oauth_token_present": oauth,
|
|
1519
1773
|
"last_fetch_age_s": (
|
|
@@ -1555,6 +1809,15 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1555
1809
|
marker = "✓" if hook_counts[ev] >= 1 else "✗"
|
|
1556
1810
|
word = "installed" if hook_counts[ev] >= 1 else "missing"
|
|
1557
1811
|
out.append(f" {ev:14s} {word:24s} {marker}")
|
|
1812
|
+
# #311: report statusLine.refreshInterval read-only — only when a
|
|
1813
|
+
# statusLine block exists (present/missing/foreign); silent for the
|
|
1814
|
+
# common `absent`/`unavailable` cases (hooks/settings warnings cover those).
|
|
1815
|
+
if sl_state == "present":
|
|
1816
|
+
out.append(f" {'refreshInterval':14s} {'set (' + str(sl_value) + ')':24s} ✓")
|
|
1817
|
+
elif sl_state == "missing":
|
|
1818
|
+
out.append(f" {'refreshInterval':14s} {'not set':24s} ⚠")
|
|
1819
|
+
elif sl_state == "foreign":
|
|
1820
|
+
out.append(f" {'refreshInterval':14s} {'n/a (custom statusLine)':24s} ✓")
|
|
1558
1821
|
if codex_hooks["roots"]:
|
|
1559
1822
|
out.append("Codex hooks")
|
|
1560
1823
|
for row in codex_hooks["roots"]:
|
|
@@ -1795,6 +2058,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1795
2058
|
dst_dir = _setup_local_bin_dir()
|
|
1796
2059
|
claude_available = _setup_claude_available()
|
|
1797
2060
|
settings: dict = {}
|
|
2061
|
+
settings_load_failed = False
|
|
1798
2062
|
if claude_available:
|
|
1799
2063
|
try:
|
|
1800
2064
|
settings = c._load_claude_settings()
|
|
@@ -1803,6 +2067,13 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1803
2067
|
# against an empty dict simply yields detected=False for entries.
|
|
1804
2068
|
# Mirror _setup_status's non-mutating warning behavior.
|
|
1805
2069
|
eprint(f"setup: warning: {exc}")
|
|
2070
|
+
settings_load_failed = True
|
|
2071
|
+
# #311: classify statusLine.refreshInterval. Preserve the SetupError
|
|
2072
|
+
# sentinel (classify from None, NOT the coerced {}) so a malformed
|
|
2073
|
+
# settings.json previews `unavailable`, never a false `absent`.
|
|
2074
|
+
sl_state, sl_value = c._classify_statusline_refresh(
|
|
2075
|
+
None if settings_load_failed else settings
|
|
2076
|
+
)
|
|
1806
2077
|
detection = _setup_detect_legacy_bespoke_hooks(settings)
|
|
1807
2078
|
sym_results = []
|
|
1808
2079
|
for name in c.SETUP_SYMLINK_NAMES:
|
|
@@ -1846,6 +2117,13 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1846
2117
|
)
|
|
1847
2118
|
else:
|
|
1848
2119
|
out.append("Claude Code home not present — would skip Claude hooks")
|
|
2120
|
+
# #311: preview the statusLine.refreshInterval add — ONLY in the `missing`
|
|
2121
|
+
# state (a recognized cctally statusLine block lacking the key).
|
|
2122
|
+
if sl_state == "missing":
|
|
2123
|
+
out.append(
|
|
2124
|
+
f"Would add statusLine.refreshInterval: "
|
|
2125
|
+
f"{_cctally_core.STATUSLINE_REFRESH_INTERVAL_DEFAULT}"
|
|
2126
|
+
)
|
|
1849
2127
|
for row in codex_hooks["roots"]:
|
|
1850
2128
|
if row["state"] == "malformed":
|
|
1851
2129
|
out.append(f"Codex hooks malformed at {row['hooks_path']}: {row['error']}")
|
|
@@ -1938,6 +2216,12 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1938
2216
|
],
|
|
1939
2217
|
"settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
|
|
1940
2218
|
},
|
|
2219
|
+
# #311: additive statusLine.refreshInterval preview object. `action`
|
|
2220
|
+
# is `would_add` only in the `missing` state, else `none`.
|
|
2221
|
+
"statusline_refresh": {
|
|
2222
|
+
"state": sl_state, "value": sl_value,
|
|
2223
|
+
"action": "would_add" if sl_state == "missing" else "none",
|
|
2224
|
+
},
|
|
1941
2225
|
"codex_hooks": codex_hooks,
|
|
1942
2226
|
# Sibling parity with `_setup_status` and `_setup_install`
|
|
1943
2227
|
# JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
|
|
@@ -2204,6 +2488,18 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2204
2488
|
eprint(f"setup: {exc}")
|
|
2205
2489
|
return 1
|
|
2206
2490
|
|
|
2491
|
+
# #311: statusLine.refreshInterval — add-when-absent. Classify BEFORE the
|
|
2492
|
+
# merge (for the text/JSON report) then mutate; the mutation rides the same
|
|
2493
|
+
# atomic backup+write below (no second write). Only mutates in the `missing`
|
|
2494
|
+
# state. When Claude isn't available there's no settings to consult →
|
|
2495
|
+
# `unavailable`, action none.
|
|
2496
|
+
sl_state_pre, sl_value_pre = (
|
|
2497
|
+
c._classify_statusline_refresh(settings) if claude_available
|
|
2498
|
+
else ("unavailable", None)
|
|
2499
|
+
)
|
|
2500
|
+
sl_added = bool(claude_available) and \
|
|
2501
|
+
c._settings_merge_statusline_refresh_interval(settings)
|
|
2502
|
+
|
|
2207
2503
|
# Clean up symlinks left behind by prior cctally versions whose
|
|
2208
2504
|
# subcommand surface has changed (e.g. v1.9.0 retired
|
|
2209
2505
|
# `cctally-release` when release tooling went private). Only unlinks
|
|
@@ -2363,6 +2659,20 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2363
2659
|
# — matches the spec's success-path sample (Section 2).
|
|
2364
2660
|
if claude_available:
|
|
2365
2661
|
out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}")
|
|
2662
|
+
# #311: report the statusLine.refreshInterval outcome — only when a
|
|
2663
|
+
# statusLine block exists (present/foreign/missing); silent for the
|
|
2664
|
+
# common `absent` case so a fresh install with no statusLine is quiet.
|
|
2665
|
+
if sl_added:
|
|
2666
|
+
out.append(
|
|
2667
|
+
f"✓ Added statusLine.refreshInterval: "
|
|
2668
|
+
f"{_cctally_core.STATUSLINE_REFRESH_INTERVAL_DEFAULT} to settings.json"
|
|
2669
|
+
)
|
|
2670
|
+
elif sl_state_pre == "present":
|
|
2671
|
+
out.append(
|
|
2672
|
+
f" statusLine.refreshInterval unchanged (user value: {sl_value_pre})"
|
|
2673
|
+
)
|
|
2674
|
+
elif sl_state_pre == "foreign":
|
|
2675
|
+
out.append(" statusLine.refreshInterval skipped (custom statusLine command)")
|
|
2366
2676
|
|
|
2367
2677
|
if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
|
|
2368
2678
|
files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
|
|
@@ -2511,6 +2821,16 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2511
2821
|
"events_added": list(c.SETUP_HOOK_EVENTS) if claude_available else [],
|
|
2512
2822
|
"settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
|
|
2513
2823
|
},
|
|
2824
|
+
# #311: additive statusLine.refreshInterval object. `state`/`value`
|
|
2825
|
+
# reflect the RESULT (post-merge) so an add reports present/30;
|
|
2826
|
+
# `action` is `added` only when this install inserted the key.
|
|
2827
|
+
"statusline_refresh": (
|
|
2828
|
+
{"state": "present",
|
|
2829
|
+
"value": _cctally_core.STATUSLINE_REFRESH_INTERVAL_DEFAULT,
|
|
2830
|
+
"action": "added"}
|
|
2831
|
+
if sl_added else
|
|
2832
|
+
{"state": sl_state_pre, "value": sl_value_pre, "action": "none"}
|
|
2833
|
+
),
|
|
2514
2834
|
"codex_hooks": codex_hooks,
|
|
2515
2835
|
"auth": {
|
|
2516
2836
|
"oauth_token_present": oauth,
|
|
@@ -449,6 +449,17 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
|
449
449
|
INLINE (no fork) so persistence tests are deterministic and no detached
|
|
450
450
|
child outlives fixture cleanup."""
|
|
451
451
|
c = _cctally()
|
|
452
|
+
# 0. Pool-identity guard (spec 2026-07-17 #311 D1). A bracket-variant
|
|
453
|
+
# model id (e.g. `claude-opus-4-8[1m]`) reports a SEPARATE rate-limit
|
|
454
|
+
# pool; persisting it poisons the default-pool DB (HWM latch + dedup
|
|
455
|
+
# freeze). Skip BEFORE the lock/fork AND before touching the
|
|
456
|
+
# observation marker — a foreign-pool session is not evidence the
|
|
457
|
+
# regular-pool pipeline is alive, so the OAuth backfill must keep
|
|
458
|
+
# aging. Render is unchanged (this is persist-only). `.model_id` is a
|
|
459
|
+
# dataclass attr, not a dict key, so this never AttributeErrors into
|
|
460
|
+
# cmd_statusline's silent `except`.
|
|
461
|
+
if _lib_statusline.is_alternate_pool_model_id(parsed.model_id):
|
|
462
|
+
return
|
|
452
463
|
# 1. Require a usable 7d reading. Absence is a clean no-op (older CC / CC
|
|
453
464
|
# not supplying rate_limits — the OAuth backfill covers that case).
|
|
454
465
|
if parsed.rate_limits_7d_pct is None or parsed.rate_limits_7d_resets_at is None:
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -204,6 +204,15 @@ class DoctorState:
|
|
|
204
204
|
codex_quota_windows: Optional[list[dict]] = None
|
|
205
205
|
codex_hook_roots: Optional[list[dict]] = None
|
|
206
206
|
codex_lifecycle_activity_24h: Optional[dict] = None
|
|
207
|
+
# #311: precomputed five-state classification of settings.json's
|
|
208
|
+
# statusLine.refreshInterval (unavailable/absent/foreign/present/missing),
|
|
209
|
+
# computed by doctor_gather_state via the setup I/O-layer classifier so the
|
|
210
|
+
# kernel stays I/O-free (it never imports bin/_cctally_setup). Defaulted
|
|
211
|
+
# (placed last after the other defaulted tail fields) so existing
|
|
212
|
+
# constructors stay valid; the default "unavailable" is the always-OK,
|
|
213
|
+
# never-WARN posture (the hooks.installed / settings warnings already
|
|
214
|
+
# surface a genuinely-unreadable settings.json — no double-WARN here).
|
|
215
|
+
statusline_refresh_state: str = "unavailable"
|
|
207
216
|
|
|
208
217
|
|
|
209
218
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -441,6 +450,44 @@ def _check_hooks_installed(s: DoctorState) -> CheckResult:
|
|
|
441
450
|
)
|
|
442
451
|
|
|
443
452
|
|
|
453
|
+
_STATUSLINE_REFRESH_SUMMARIES = {
|
|
454
|
+
"present": "set",
|
|
455
|
+
"missing": "not set on a cctally statusLine",
|
|
456
|
+
"absent": "no statusLine configured",
|
|
457
|
+
"foreign": "n/a (custom statusLine command)",
|
|
458
|
+
"unavailable": "settings.json unreadable",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _check_statusline_refresh_interval(s: DoctorState) -> CheckResult:
|
|
463
|
+
"""WARN only when a recognized cctally ``statusLine`` command lacks a
|
|
464
|
+
``refreshInterval`` (#311): without it, statusline-fed usage persistence
|
|
465
|
+
goes quiet while a parent session waits on a long subagent. Every other
|
|
466
|
+
state is OK with its own STABLE summary — the not-applicable states
|
|
467
|
+
(absent/foreign) say so, and `unavailable` says settings were unreadable
|
|
468
|
+
(the hooks.installed / settings warnings already surface that failure, so
|
|
469
|
+
no double-WARN). The kernel reads only the precomputed scalar; the setup
|
|
470
|
+
classifier's I/O happens in doctor_gather_state."""
|
|
471
|
+
state = s.statusline_refresh_state
|
|
472
|
+
summary = _STATUSLINE_REFRESH_SUMMARIES.get(state, _STATUSLINE_REFRESH_SUMMARIES["unavailable"])
|
|
473
|
+
if state == "missing":
|
|
474
|
+
return CheckResult(
|
|
475
|
+
id="hooks.statusline_refresh_interval",
|
|
476
|
+
title="statusLine refreshInterval", severity="warn",
|
|
477
|
+
summary=summary,
|
|
478
|
+
remediation=(
|
|
479
|
+
"Run `cctally setup` to add statusLine.refreshInterval, or set "
|
|
480
|
+
"it manually — see docs/commands/statusline.md"
|
|
481
|
+
),
|
|
482
|
+
details={"state": state},
|
|
483
|
+
)
|
|
484
|
+
return CheckResult(
|
|
485
|
+
id="hooks.statusline_refresh_interval",
|
|
486
|
+
title="statusLine refreshInterval", severity="ok",
|
|
487
|
+
summary=summary, remediation=None, details={"state": state},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
444
491
|
def _check_hooks_recent_activity_24h(s: DoctorState) -> CheckResult:
|
|
445
492
|
act = s.log_activity_24h or {"fires": 0, "errors": 0,
|
|
446
493
|
"by_event": {}, "last_fire_ago_s": None,
|
|
@@ -1590,6 +1637,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1590
1637
|
)),
|
|
1591
1638
|
("hooks", "Hooks", (
|
|
1592
1639
|
("hooks.installed", "_check_hooks_installed"),
|
|
1640
|
+
("hooks.statusline_refresh_interval", "_check_statusline_refresh_interval"),
|
|
1593
1641
|
("hooks.recent_activity_24h", "_check_hooks_recent_activity_24h"),
|
|
1594
1642
|
("hooks.last_fire_age", "_check_hooks_last_fire_age"),
|
|
1595
1643
|
("hooks.codex_installed", "_check_hooks_codex_installed"),
|
package/bin/_lib_statusline.py
CHANGED
|
@@ -191,6 +191,32 @@ def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError"
|
|
|
191
191
|
)
|
|
192
192
|
|
|
193
193
|
|
|
194
|
+
# ---- Pool-identity guard (persist-only; spec 2026-07-17 #311 D1) ----------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_ALTERNATE_POOL_MODEL_ID_RE = re.compile(r"\[[^\]]+\]$")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_alternate_pool_model_id(model_id) -> bool:
|
|
201
|
+
"""True iff ``model_id`` is a bracketed variant id (e.g.
|
|
202
|
+
``claude-opus-4-8[1m]``) that reports a SEPARATE rate-limit pool.
|
|
203
|
+
|
|
204
|
+
Such a session's stdin ``rate_limits`` describes a DIFFERENT usage pool
|
|
205
|
+
on the same account; persisting it poisons the default-pool DB (the 7d
|
|
206
|
+
HWM clamp latches the foreign high value and dedup then freezes tracking
|
|
207
|
+
— see the #311 spec). The persist feeder skips it.
|
|
208
|
+
|
|
209
|
+
Matches ANY trailing bracket suffix, not just the literal ``[1m]``: a
|
|
210
|
+
future variant that turns out to share the default pool would merely lose
|
|
211
|
+
one redundant writer (fail-safe toward data purity), whereas matching
|
|
212
|
+
only ``[1m]`` would let the next variant poison the DB again. Missing /
|
|
213
|
+
``None`` / non-string / no-suffix ids return ``False`` (persist proceeds —
|
|
214
|
+
only a positive variant match skips). Never raises."""
|
|
215
|
+
if not isinstance(model_id, str) or not model_id:
|
|
216
|
+
return False
|
|
217
|
+
return _ALTERNATE_POOL_MODEL_ID_RE.search(model_id) is not None
|
|
218
|
+
|
|
219
|
+
|
|
194
220
|
# ---- Segment 1: model -----------------------------------------------------
|
|
195
221
|
|
|
196
222
|
|
package/bin/cctally
CHANGED
|
@@ -704,6 +704,15 @@ _cctally_setup = _load_sibling("_cctally_setup")
|
|
|
704
704
|
_settings_merge_install = _cctally_setup._settings_merge_install
|
|
705
705
|
_settings_merge_uninstall = _cctally_setup._settings_merge_uninstall
|
|
706
706
|
_settings_merge_unwire_legacy = _cctally_setup._settings_merge_unwire_legacy
|
|
707
|
+
# #311: statusLine.refreshInterval recognizer / classifier / merge. Re-exported
|
|
708
|
+
# so doctor's gather site (c._classify_statusline_refresh) and the setup tests
|
|
709
|
+
# reach the one implementation; the classifier is reached from _cctally_doctor,
|
|
710
|
+
# never imported into the I/O-free _lib_doctor kernel.
|
|
711
|
+
_is_cctally_statusline_command = _cctally_setup._is_cctally_statusline_command
|
|
712
|
+
_classify_statusline_refresh = _cctally_setup._classify_statusline_refresh
|
|
713
|
+
_settings_merge_statusline_refresh_interval = (
|
|
714
|
+
_cctally_setup._settings_merge_statusline_refresh_interval
|
|
715
|
+
)
|
|
707
716
|
_setup_resolve_repo_root = _cctally_setup._setup_resolve_repo_root
|
|
708
717
|
_setup_local_bin_dir = _cctally_setup._setup_local_bin_dir
|
|
709
718
|
_SetupSymlinkResult = _cctally_setup._SetupSymlinkResult
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.70.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": {
|