cctally 1.69.3 → 1.71.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 +24 -0
- package/bin/_cctally_cache.py +167 -2
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +32 -3
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +159 -0
- package/bin/_cctally_doctor.py +10 -0
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- 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 +10 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
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
|
|
@@ -943,6 +952,7 @@ cmd_db_skip = _cctally_db.cmd_db_skip
|
|
|
943
952
|
cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
944
953
|
cmd_db_recover = _cctally_db.cmd_db_recover
|
|
945
954
|
cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
|
|
955
|
+
cmd_db_vacuum = _cctally_db.cmd_db_vacuum
|
|
946
956
|
|
|
947
957
|
|
|
948
958
|
# Session-entry cache subsystem (Claude + Codex) — read-through delta
|