cctally 1.69.2 → 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 CHANGED
@@ -5,6 +5,23 @@ 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
+
20
+ ## [1.69.3] - 2026-07-17
21
+
22
+ ### Fixed
23
+ - Dashboard: cleared the persistent `⚠ sync error` chip (`source-bundle: data_version must be a non-empty string`) that appeared once a provider — typically Codex — stayed degraded or unavailable across two or more consecutive sync ticks. The first failure records an unavailable provider generation; the next tick then tried to retain that already-unavailable generation as a `partial` state with an empty data version, which fails the source-state validator. `degrade_source_state` now stays `unavailable` (carrying the new warning) when there is no coherent prior generation to retain.
24
+
8
25
  ## [1.69.2] - 2026-07-17
9
26
 
10
27
  ### Fixed
@@ -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 = 60.0
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
@@ -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
 
@@ -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:
@@ -230,6 +230,13 @@ def degrade_source_state(
230
230
  raise ValueError("prior must be a SourceDashboardState")
231
231
  if not isinstance(warning, SourceDashboardWarning):
232
232
  raise ValueError("warning must be a SourceDashboardWarning")
233
+ # There must be a coherent prior generation to retain. An unavailable prior
234
+ # carries no data and an empty ``data_version``; degrading it to ``partial``
235
+ # would build an invalid state (the non-empty-data_version invariant only
236
+ # exempts ``unavailable``) and raise. Stay unavailable, carrying the new
237
+ # warning — this is the 2nd+ consecutive failing sync of a degraded provider.
238
+ if prior.availability == "unavailable" or not prior.data_version:
239
+ return unavailable_source_state(prior.source, warning)
233
240
  return SourceDashboardState(
234
241
  source=prior.source,
235
242
  availability="partial",
@@ -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"),
@@ -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.69.2",
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": {