cctally 1.68.0 → 1.69.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -163,6 +163,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
163
163
  from __future__ import annotations
164
164
 
165
165
  import argparse
166
+ import contextlib
166
167
  import datetime as dt
167
168
  import fcntl
168
169
  import json
@@ -201,6 +202,13 @@ from _cctally_core import (
201
202
  )
202
203
  from _lib_five_hour import _canonical_5h_window_key, five_hour_milestone_range
203
204
  from _lib_pricing import _calculate_entry_cost
205
+ from _lib_codex_hooks import (
206
+ CODEX_HOOK_THROTTLE_SECONDS,
207
+ acquire_due_lifecycle_locks,
208
+ codex_hook_roots,
209
+ mark_lifecycle_success,
210
+ release_lifecycle_locks,
211
+ )
204
212
 
205
213
 
206
214
  import importlib.util as _ilu
@@ -794,8 +802,9 @@ def maybe_record_milestone(
794
802
 
795
803
 
796
804
  def _record_budget_milestone_for_vendor(
797
- *, vendor, target, thresholds, period, config, tz, build_payload
798
- ) -> None:
805
+ *, vendor, target, thresholds, period, config, tz, build_payload,
806
+ raise_errors: bool = False,
807
+ ) -> int:
799
808
  """Shared budget-milestone firing core for both vendors (#143).
800
809
 
801
810
  Hot-path ordering is preserved verbatim (spec §4.2 / [Pre-probe before
@@ -827,7 +836,7 @@ def _record_budget_milestone_for_vendor(
827
836
  config=config, tz=tz,
828
837
  )
829
838
  if start_at is None:
830
- return # no resolvable window yet (claude subscription-week pre-snapshot)
839
+ return 0 # no resolvable window yet (claude subscription-week pre-snapshot)
831
840
  period_key = start_at.isoformat(timespec="seconds")
832
841
 
833
842
  present = {
@@ -840,7 +849,7 @@ def _record_budget_milestone_for_vendor(
840
849
  }
841
850
  pending = [t for t in sorted(thresholds) if t not in present]
842
851
  if not pending:
843
- return # nothing left this window → skip the cost SUM
852
+ return 0 # nothing left this window → skip the cost SUM
844
853
 
845
854
  spent = _budget_spend_for_vendor(
846
855
  conn, vendor=vendor, start_at=start_at, now_utc=now_utc
@@ -870,6 +879,8 @@ def _record_budget_milestone_for_vendor(
870
879
  conn.commit()
871
880
  except Exception as exc:
872
881
  eprint(f"[budget-milestone:{vendor}] error recording budget milestone: {exc}")
882
+ if raise_errors:
883
+ raise
873
884
  finally:
874
885
  conn.close()
875
886
 
@@ -881,6 +892,7 @@ def _record_budget_milestone_for_vendor(
881
892
  _dispatch_alert_notification(payload, mode="real")
882
893
  except Exception as dispatch_exc:
883
894
  eprint(f"[budget-alerts:{vendor}] dispatch failed: {dispatch_exc}")
895
+ return len(pending_alerts)
884
896
 
885
897
 
886
898
  def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
@@ -1108,7 +1120,9 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
1108
1120
  eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
1109
1121
 
1110
1122
 
1111
- def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1123
+ def maybe_record_codex_budget_milestone(
1124
+ saved: dict[str, Any], *, raise_errors: bool = False,
1125
+ ) -> int:
1112
1126
  """Fire Codex budget alerts on ACTUAL-Codex-spend threshold crossings (axis
1113
1127
  ``codex_budget``, calendar-period-codex-budgets spec §6 — the gap the Codex
1114
1128
  spec review flagged: Codex usage never flows through ``record-usage``, so the
@@ -1143,16 +1157,16 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1143
1157
  budget_cfg = _get_budget_config(config)
1144
1158
  except _BudgetConfigError as exc:
1145
1159
  _warn_budget_bad_config_once(exc)
1146
- return
1160
+ return 0
1147
1161
  codex_cfg = budget_cfg.get("codex")
1148
1162
  if not codex_cfg or not codex_cfg.get("alerts_enabled"):
1149
- return
1163
+ return 0
1150
1164
  target = codex_cfg.get("amount_usd")
1151
1165
  thresholds = codex_cfg.get("alert_thresholds") or []
1152
1166
  if target is None or not thresholds:
1153
- return
1167
+ return 0
1154
1168
  tz = resolve_display_tz(argparse.Namespace(tz=None), config)
1155
- _record_budget_milestone_for_vendor(
1169
+ return _record_budget_milestone_for_vendor(
1156
1170
  vendor="codex",
1157
1171
  target=target,
1158
1172
  thresholds=thresholds,
@@ -1171,6 +1185,7 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1171
1185
  spent_usd=kw["spent_usd"],
1172
1186
  consumption_pct=kw["consumption_pct"],
1173
1187
  ),
1188
+ raise_errors=raise_errors,
1174
1189
  )
1175
1190
 
1176
1191
 
@@ -3562,7 +3577,13 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3562
3577
  return 0
3563
3578
 
3564
3579
  payload = {
3565
- "source": "statusline",
3580
+ # Record the true feeder. Defaults to "statusline" for the public
3581
+ # `record-usage` CLI (preserves prior behavior); the OAuth callers
3582
+ # (_hook_tick_oauth_refresh / _refresh_usage_inproc) pass "api", and
3583
+ # the statusline persist feeder passes "statusline". Previously this
3584
+ # was hard-coded "statusline" for EVERY caller, mislabeling OAuth
3585
+ # rows (spec §5). No migration — the column already exists.
3586
+ "source": getattr(args, "source", "statusline"),
3566
3587
  "capturedAt": now_utc_iso(),
3567
3588
  "weeklyPercent": weekly_percent,
3568
3589
  "weekStartDate": week_start_date,
@@ -3735,6 +3756,166 @@ def _hook_tick_throttle_touch() -> None:
3735
3756
  pass
3736
3757
 
3737
3758
 
3759
+ # =========================================================================
3760
+ # Statusline observation marker + OAuth backoff deadline (spec 2026-07-17)
3761
+ # =========================================================================
3762
+ #
3763
+ # Two independent markers with two DIFFERENT time encodings:
3764
+ # - The observation marker is MTIME-based (mirrors _hook_tick_throttle_*):
3765
+ # it answers "how long since the statusline last fed?", so its age is
3766
+ # the signal. It is touched even on a dedup no-op.
3767
+ # - The OAuth backoff marker is CONTENT-based: it stores a FUTURE absolute
3768
+ # epoch deadline as text. Its mtime is meaningless (~now). Encoding the
3769
+ # deadline as mtime would future-date the file and, if ever confused
3770
+ # with a throttle marker, corrupt an mtime-age reading — hence the
3771
+ # deliberate split (Codex P1-3).
3772
+
3773
+
3774
+ def _statusline_observe_age_seconds() -> float:
3775
+ """Seconds since the statusline last successfully fed usage; +inf if
3776
+ never. Mtime-based, mirroring ``_hook_tick_throttle_age_seconds``."""
3777
+ try:
3778
+ mtime = _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.stat().st_mtime
3779
+ except FileNotFoundError:
3780
+ return float("inf")
3781
+ except OSError:
3782
+ return float("inf")
3783
+ return max(0.0, time.time() - mtime)
3784
+
3785
+
3786
+ def _statusline_observe_touch() -> None:
3787
+ """Mark the statusline as alive (mtime := now, creating if missing).
3788
+
3789
+ Called after every persist attempt that fed valid 7d input through
3790
+ ``cmd_record_usage`` successfully — INCLUDING a dedup (no-insert)
3791
+ return, so the throttles reflect liveness rather than snapshot age."""
3792
+ try:
3793
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
3794
+ _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.touch(exist_ok=True)
3795
+ os.utime(_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH, None)
3796
+ except OSError:
3797
+ pass
3798
+
3799
+
3800
+ def _oauth_backoff_remaining_seconds() -> float:
3801
+ """Seconds until the shared OAuth 429 backoff deadline; ``0.0`` when the
3802
+ marker is absent, empty, malformed, or already elapsed.
3803
+
3804
+ Reads the ABSOLUTE epoch deadline from the marker's text CONTENT (not
3805
+ its mtime) and returns ``max(0.0, deadline - now)``."""
3806
+ try:
3807
+ raw = _cctally_core.OAUTH_BACKOFF_MARKER_PATH.read_text()
3808
+ except FileNotFoundError:
3809
+ return 0.0
3810
+ except OSError:
3811
+ return 0.0
3812
+ try:
3813
+ deadline = float(raw.strip())
3814
+ except (TypeError, ValueError):
3815
+ return 0.0
3816
+ return max(0.0, deadline - time.time())
3817
+
3818
+
3819
+ def _oauth_backoff_set(deadline_epoch: float) -> None:
3820
+ """Persist the shared OAuth 429 backoff deadline (absolute epoch).
3821
+
3822
+ Never SHORTENS an existing deadline: writes ``max(deadline_epoch,
3823
+ existing_deadline)`` so concurrent/repeated 429s keep the furthest-out
3824
+ cooldown. The write is atomic (tmp + ``os.replace``) so a reader never
3825
+ sees a half-written file. Best-effort — any OSError is swallowed."""
3826
+ try:
3827
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
3828
+ except OSError:
3829
+ pass
3830
+ # Compare against the existing ABSOLUTE deadline, not the remaining
3831
+ # seconds, so "never shorten" holds regardless of when we read it.
3832
+ existing_abs = 0.0
3833
+ try:
3834
+ existing_raw = _cctally_core.OAUTH_BACKOFF_MARKER_PATH.read_text()
3835
+ existing_abs = float(existing_raw.strip())
3836
+ except (FileNotFoundError, OSError, TypeError, ValueError):
3837
+ existing_abs = 0.0
3838
+ target = max(float(deadline_epoch), existing_abs)
3839
+ path = _cctally_core.OAUTH_BACKOFF_MARKER_PATH
3840
+ tmp = path.with_name(path.name + ".tmp")
3841
+ try:
3842
+ tmp.write_text(f"{target:.6f}")
3843
+ os.replace(tmp, path)
3844
+ except OSError:
3845
+ try:
3846
+ tmp.unlink()
3847
+ except OSError:
3848
+ pass
3849
+
3850
+
3851
+ def _oauth_backoff_clear() -> None:
3852
+ """Remove the OAuth backoff deadline marker (ignore if absent)."""
3853
+ try:
3854
+ _cctally_core.OAUTH_BACKOFF_MARKER_PATH.unlink()
3855
+ except FileNotFoundError:
3856
+ pass
3857
+ except OSError:
3858
+ pass
3859
+
3860
+
3861
+ def _oauth_backoff_count() -> int:
3862
+ """The consecutive-429 count (0 when absent/malformed)."""
3863
+ try:
3864
+ raw = _cctally_core.OAUTH_BACKOFF_COUNT_PATH.read_text()
3865
+ except (FileNotFoundError, OSError):
3866
+ return 0
3867
+ try:
3868
+ return max(0, int(raw.strip()))
3869
+ except (TypeError, ValueError):
3870
+ return 0
3871
+
3872
+
3873
+ def _oauth_backoff_register_429(*, retry_after_deadline, now) -> float:
3874
+ """Record a 429: set/extend the shared backoff deadline and bump the
3875
+ consecutive-429 counter. Returns the effective deadline (absolute epoch).
3876
+
3877
+ Policy (spec §4):
3878
+ - A valid ``Retry-After`` (``retry_after_deadline`` is not None) is used
3879
+ verbatim.
3880
+ - Otherwise conservative exponential backoff:
3881
+ ``now + min(CAP, BASE * 2**consecutive_429)``.
3882
+ - ``_oauth_backoff_set`` keeps the MAX, so concurrent/repeated 429s
3883
+ never shorten the cooldown."""
3884
+ count = _oauth_backoff_count()
3885
+ if retry_after_deadline is not None:
3886
+ deadline = float(retry_after_deadline)
3887
+ else:
3888
+ base = float(_cctally_core.OAUTH_BACKOFF_BASE_SECONDS)
3889
+ cap = float(_cctally_core.OAUTH_BACKOFF_CAP_SECONDS)
3890
+ # Clamp the exponent so a corrupt/huge counter can't overflow 2**n.
3891
+ exp = 2 ** min(count, 30)
3892
+ deadline = float(now) + min(cap, base * exp)
3893
+ _oauth_backoff_set(deadline)
3894
+ # Bump the counter atomically (best-effort).
3895
+ try:
3896
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
3897
+ path = _cctally_core.OAUTH_BACKOFF_COUNT_PATH
3898
+ tmp = path.with_name(path.name + ".tmp")
3899
+ tmp.write_text(str(count + 1))
3900
+ os.replace(tmp, path)
3901
+ except OSError:
3902
+ try:
3903
+ tmp.unlink()
3904
+ except (OSError, NameError, UnboundLocalError):
3905
+ pass
3906
+ return deadline
3907
+
3908
+
3909
+ def _oauth_backoff_reset() -> None:
3910
+ """Clear the backoff deadline AND the consecutive-429 counter — called on
3911
+ any successful OAuth API response (spec §4)."""
3912
+ _oauth_backoff_clear()
3913
+ try:
3914
+ _cctally_core.OAUTH_BACKOFF_COUNT_PATH.unlink()
3915
+ except (FileNotFoundError, OSError):
3916
+ pass
3917
+
3918
+
3738
3919
  def _hook_tick_read_stdin_event(stdin_max_bytes: int = 32 * 1024) -> dict:
3739
3920
  """Read CC's hook payload (JSON on stdin). Best-effort.
3740
3921
 
@@ -3783,6 +3964,116 @@ def _hook_tick_format_log_line(
3783
3964
  )
3784
3965
 
3785
3966
 
3967
+ def _codex_lifecycle_log_line(
3968
+ *, source_root_key: str, event: str, sync: str, result: str,
3969
+ blocks: int, milestones: int, alert_eligible_roots: int,
3970
+ quota_alerts: int, budget_alerts: int, dur_ms: int,
3971
+ ) -> str:
3972
+ """Render one privacy-safe root-qualified Codex lifecycle outcome.
3973
+
3974
+ Native hook input can contain session paths and conversation identifiers;
3975
+ this durable diagnostic deliberately carries only the bounded event label,
3976
+ opaque source root key, aggregate reconciliation counts, and duration.
3977
+ """
3978
+ safe_event = "".join(
3979
+ char for char in str(event)[:40] if char.isalnum() or char in "-_"
3980
+ ) or "unknown"
3981
+ return (
3982
+ f"{now_utc_iso()} provider=codex source_root_key={source_root_key} "
3983
+ f"event={safe_event} sync={sync} blocks={int(blocks)} "
3984
+ f"milestones={int(milestones)} "
3985
+ f"alert_eligible_roots={int(alert_eligible_roots)} "
3986
+ f"quota_alerts={int(quota_alerts)} budget_alerts={int(budget_alerts)} "
3987
+ f"dur_ms={max(0, int(dur_ms))} result={result}"
3988
+ )
3989
+
3990
+
3991
+ def _codex_lifecycle_roots():
3992
+ """Snapshot usable configured Codex homes in stable root-key order."""
3993
+ return codex_hook_roots(_cctally()._codex_home_roots())
3994
+
3995
+
3996
+ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") -> int:
3997
+ """Run one quiet, foreground Codex lifecycle tick.
3998
+
3999
+ Native Codex Stop/SubagentStop hooks may fire concurrently. Per-root
4000
+ lifecycle locks narrow alert eligibility while the one S1 cache sync and
4001
+ reporting reconciliation still observe the complete active root set.
4002
+ """
4003
+ c = _cctally()
4004
+ roots = _codex_lifecycle_roots()
4005
+ locks = acquire_due_lifecycle_locks(
4006
+ _cctally_core.APP_DIR,
4007
+ roots,
4008
+ now=time.time(),
4009
+ throttle_seconds=CODEX_HOOK_THROTTLE_SECONDS,
4010
+ )
4011
+ if not locks:
4012
+ return 0
4013
+ all_root_keys = tuple(root.source_root_key for root in roots)
4014
+ due_root_keys = tuple(lock.root.source_root_key for lock in locks)
4015
+ started_at = time.monotonic()
4016
+
4017
+ def log_outcome(
4018
+ *, sync: str, result: str, projection=None, budget_alerts: int = 0,
4019
+ ) -> None:
4020
+ blocks = int(getattr(projection, "blocks_upserted", 0) or 0)
4021
+ milestones = int(getattr(projection, "milestones_upserted", 0) or 0)
4022
+ quota_alerts = int(getattr(projection, "alerts_dispatched", 0) or 0)
4023
+ dur_ms = int((time.monotonic() - started_at) * 1000)
4024
+ for lock in locks:
4025
+ _hook_tick_log_line(_codex_lifecycle_log_line(
4026
+ source_root_key=lock.root.source_root_key,
4027
+ event=event,
4028
+ sync=sync,
4029
+ result=result,
4030
+ blocks=blocks,
4031
+ milestones=milestones,
4032
+ alert_eligible_roots=len(due_root_keys),
4033
+ quota_alerts=quota_alerts,
4034
+ budget_alerts=budget_alerts,
4035
+ dur_ms=dur_ms,
4036
+ ))
4037
+ _hook_tick_log_rotate_if_needed()
4038
+
4039
+ try:
4040
+ # Hook stdout/stderr is contractually silent. Cache migration and
4041
+ # ingest diagnostics remain available to explicit CLI operations.
4042
+ with open(os.devnull, "w", encoding="utf-8") as quiet, \
4043
+ contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
4044
+ cache = c.open_cache_db()
4045
+ try:
4046
+ stats = c.sync_codex_cache(cache, lock_timeout=0)
4047
+ finally:
4048
+ cache.close()
4049
+ if stats.lock_contended:
4050
+ log_outcome(sync="contended", result="noop")
4051
+ return 0
4052
+ projection = c.reconcile_codex_quota_projection(
4053
+ source_root_keys=all_root_keys,
4054
+ alert_eligible_root_keys=due_root_keys,
4055
+ now=dt.datetime.now(dt.timezone.utc),
4056
+ )
4057
+ # Vendor-scoped spend is intentionally evaluated once per
4058
+ # successful due-set tick, not once per root.
4059
+ budget_alerts = c.maybe_record_codex_budget_milestone(
4060
+ {}, raise_errors=True,
4061
+ )
4062
+ mark_lifecycle_success(locks)
4063
+ log_outcome(
4064
+ sync="ok", result="success", projection=projection,
4065
+ budget_alerts=budget_alerts,
4066
+ )
4067
+ except Exception:
4068
+ # A failed sync, projection, or budget evaluation must acknowledge no
4069
+ # root. Hooks are best-effort and remain a successful no-op to Codex.
4070
+ log_outcome(sync="error", result="error")
4071
+ return 0
4072
+ finally:
4073
+ release_lifecycle_locks(locks)
4074
+ return 0
4075
+
4076
+
3786
4077
  def cmd_hook_tick(args: argparse.Namespace) -> int:
3787
4078
  """Per-fire hook runtime (Section 3 of onboarding spec).
3788
4079
 
@@ -3790,11 +4081,25 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3790
4081
  sync_cache + (throttled) OAuth refresh, writes one log line, returns 0
3791
4082
  UNCONDITIONALLY (even on internal failure — hook discipline).
3792
4083
 
3793
- --explain mode: synchronous, prints to stdout, returns informative
3794
- exit code.
4084
+ --foreground mode: reads stdin and runs the normal best-effort body in the
4085
+ current process without detaching. --explain mode is synchronous, prints
4086
+ to stdout, and returns an informative exit code.
3795
4087
  """
3796
4088
  c = _cctally()
4089
+ source = getattr(args, "source", "claude")
4090
+ if source == "codex":
4091
+ # Codex's native handler always uses --foreground. Drain stdin before
4092
+ # any further decision so its event payload is never lost to detaching
4093
+ # shell semantics; the lifecycle body itself is intentionally quiet.
4094
+ meta = _hook_tick_read_stdin_event()
4095
+ # The production reader always returns a mapping, but this boundary is
4096
+ # deliberately best-effort: hook callers and lightweight lifecycle
4097
+ # probes may only drain stdin. Do not turn an absent payload into a
4098
+ # hook failure merely because event observability is unavailable.
4099
+ event = meta.get("event", "unknown") if isinstance(meta, dict) else "unknown"
4100
+ return _cmd_hook_tick_codex(args, event=event)
3797
4101
  explain = bool(getattr(args, "explain", False))
4102
+ foreground = bool(getattr(args, "foreground", False))
3798
4103
  no_oauth = bool(getattr(args, "no_oauth", False))
3799
4104
  # Use an explicit `is None` check so `--throttle-seconds 0` survives the
3800
4105
  # default-fallback (a `0 or DEFAULT` short-circuit would silently drop
@@ -3834,7 +4139,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3834
4139
  # mid-stack.
3835
4140
  forked = False
3836
4141
  pid = 0
3837
- if not explain:
4142
+ if not explain and not foreground:
3838
4143
  try:
3839
4144
  pid = os.fork()
3840
4145
  forked = True
@@ -3855,7 +4160,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3855
4160
  # In the inline-fallback path the parent process re-routes its own stdout/
3856
4161
  # stderr to the log file for the rest of its short life. Function returns
3857
4162
  # immediately after Step 7, so the leak is bounded.
3858
- if not explain:
4163
+ if not explain and not foreground:
3859
4164
  try:
3860
4165
  _cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
3861
4166
  log_fd = os.open(
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
  import argparse
40
40
  import dataclasses
41
41
  import datetime as dt
42
+ import email.utils
42
43
  import json
43
44
  import os
44
45
  import pathlib
@@ -46,6 +47,7 @@ import re
46
47
  import socket
47
48
  import subprocess
48
49
  import sys
50
+ import time
49
51
  import urllib.error
50
52
  import urllib.request
51
53
 
@@ -64,6 +66,7 @@ def _cctally():
64
66
  # `_newest_snapshot_age_seconds`, `_normalize_percent`,
65
67
  # `_forecast_color_enabled`, `_discover_cc_version`, `cmd_record_usage`)
66
68
  # stay on the _cctally() accessor.
69
+ import _cctally_core
67
70
  from _cctally_core import (
68
71
  eprint,
69
72
  now_utc_iso,
@@ -87,8 +90,16 @@ class RefreshUsageRateLimitError(RefreshUsageNetworkError):
87
90
  callers that already except RefreshUsageNetworkError continue to
88
91
  catch this; specific handlers can except RefreshUsageRateLimitError
89
92
  first to branch on the rate-limit case.
93
+
94
+ ``retry_after_deadline`` carries the absolute epoch parsed from the
95
+ response's ``Retry-After`` header (spec §4), or ``None`` when the header
96
+ is absent/malformed — in which case callers apply exponential backoff.
90
97
  """
91
98
 
99
+ def __init__(self, message="", retry_after_deadline=None):
100
+ super().__init__(message)
101
+ self.retry_after_deadline = retry_after_deadline
102
+
92
103
 
93
104
  class RefreshUsageMalformedError(Exception):
94
105
  """Raised when the OAuth usage API response is unparseable or missing
@@ -227,6 +238,45 @@ def _resolve_oauth_usage_user_agent(
227
238
  # Core OAuth fetch
228
239
  # =========================================================================
229
240
 
241
+ def _parse_retry_after(header_value, now: float) -> "float | None":
242
+ """Parse an HTTP ``Retry-After`` header into an absolute epoch deadline.
243
+
244
+ Supports both RFC 7231 forms:
245
+ - delta-seconds: a non-negative integer number of seconds from ``now``.
246
+ - HTTP-date: an absolute timestamp (IMF-fixdate / RFC 850 / asctime),
247
+ parsed via ``email.utils.parsedate_to_datetime`` (stdlib).
248
+
249
+ Returns the absolute epoch, or ``None`` when the header is absent, empty,
250
+ or unparseable (the caller then falls back to exponential backoff)."""
251
+ if header_value is None:
252
+ return None
253
+ s = str(header_value).strip()
254
+ if not s:
255
+ return None
256
+ # delta-seconds (a bare non-negative integer).
257
+ try:
258
+ delta = int(s)
259
+ except ValueError:
260
+ delta = None
261
+ if delta is not None:
262
+ if delta < 0:
263
+ return None
264
+ return float(now) + float(delta)
265
+ # HTTP-date form.
266
+ try:
267
+ parsed = email.utils.parsedate_to_datetime(s)
268
+ except (TypeError, ValueError):
269
+ return None
270
+ if parsed is None:
271
+ return None
272
+ if parsed.tzinfo is None:
273
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
274
+ try:
275
+ return parsed.timestamp()
276
+ except (OverflowError, OSError, ValueError):
277
+ return None
278
+
279
+
230
280
  def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
231
281
  """GET the OAuth usage API and return the parsed JSON object.
232
282
 
@@ -259,7 +309,18 @@ def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
259
309
  pass
260
310
  msg = f"HTTP {e.code} {e.reason}" + (f": {snippet}" if snippet else "")
261
311
  if e.code == 429:
262
- raise RefreshUsageRateLimitError(msg) from e
312
+ # Parse Retry-After (delta-seconds OR HTTP-date) into an absolute
313
+ # deadline the backoff state honors (spec §4). Best-effort — a
314
+ # missing/garbage header degrades to exponential backoff downstream.
315
+ retry_after_deadline = None
316
+ try:
317
+ hdr = e.headers.get("Retry-After") if e.headers else None
318
+ retry_after_deadline = _parse_retry_after(hdr, time.time())
319
+ except Exception:
320
+ retry_after_deadline = None
321
+ raise RefreshUsageRateLimitError(
322
+ msg, retry_after_deadline=retry_after_deadline
323
+ ) from e
263
324
  raise RefreshUsageNetworkError(msg) from e
264
325
  except urllib.error.URLError as e:
265
326
  raise RefreshUsageNetworkError(f"URLError: {e.reason}") from e
@@ -545,6 +606,13 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
545
606
  try:
546
607
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
547
608
  except RefreshUsageRateLimitError as exc:
609
+ # Force-refresh bypasses the backfill/backoff GATE (user asked for
610
+ # it), but a resulting 429 MUST advance the shared backoff deadline
611
+ # so automatic polling still honors it (spec §4).
612
+ c._oauth_backoff_register_429(
613
+ retry_after_deadline=getattr(exc, "retry_after_deadline", None),
614
+ now=time.time(),
615
+ )
548
616
  return _RefreshUsageResult(status="rate_limited", fallback=True,
549
617
  reason=str(exc))
550
618
  except RefreshUsageNetworkError as exc:
@@ -556,6 +624,9 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
556
624
  status="fetch_failed",
557
625
  reason=f"invalid oauth_usage config: {exc}",
558
626
  )
627
+ # Successful API response — clear any pending 429 backoff + counter, so a
628
+ # user-confirmed recovery lets automatic polling resume (spec §4).
629
+ c._oauth_backoff_reset()
559
630
 
560
631
  seven = api.get("seven_day") or {}
561
632
  try:
@@ -606,6 +677,9 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
606
677
  five_hour_resets_at=(
607
678
  str(five_resets_epoch) if five_resets_epoch is not None else None
608
679
  ),
680
+ # This is the OAuth feeder — label the row "api" (spec §5). Without
681
+ # this, cmd_record_usage would fall back to its "statusline" default.
682
+ source="api",
609
683
  )
610
684
  try:
611
685
  rc = c.cmd_record_usage(record_args)
@@ -789,7 +863,8 @@ def _hook_tick_oauth_refresh(
789
863
  Returns (status_str, payload_or_none) where status_str is one of:
790
864
  "ok(7d=N,5h=M)" | "ok(7d=N)"
791
865
  "skipped-no-token" | "skipped(fresh:Ns)"
792
- "err(network)" | "err(parse)" | "err(record-usage=K)"
866
+ "skipped(statusline-fresh:Ns)" | "skipped(backoff:Ns)"
867
+ "err(rate-limit)" | "err(network)" | "err(parse)" | "err(record-usage=K)"
793
868
  payload_or_none is the raw OAuth-API response dict (`seven_day` /
794
869
  `five_hour`) on success, or None on any non-ok branch.
795
870
  """
@@ -802,17 +877,42 @@ def _hook_tick_oauth_refresh(
802
877
  throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
803
878
  except OauthUsageConfigError:
804
879
  throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
880
+ # Backfill gate (spec §4). The automatic OAuth poll is now a BACKFILL
881
+ # behind the statusline (the primary writer). It fetches only when the
882
+ # statusline has NOT fed recently AND no 429 cooldown is pending — so a
883
+ # live statusline keeps usage fresh with zero API calls, and a 429 streak
884
+ # is suppressed by the shared deadline instead of re-polled every tick.
885
+ # Gating on the observation marker (not snapshot age) is what makes the
886
+ # demotion correct: cmd_record_usage dedups unchanged percentages without
887
+ # refreshing captured_at, so snapshot age keeps growing while the
888
+ # statusline is alive. Force-refresh (_refresh_usage_inproc) never routes
889
+ # through here, so it is exempt (user-initiated).
890
+ obs_age = c._statusline_observe_age_seconds()
891
+ if obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS):
892
+ return f"skipped(statusline-fresh:{int(obs_age)}s)", None
893
+ backoff_remaining = c._oauth_backoff_remaining_seconds()
894
+ if backoff_remaining > 0:
895
+ return f"skipped(backoff:{int(backoff_remaining)}s)", None
805
896
  age_s = c._newest_snapshot_age_seconds()
806
897
  if age_s is not None and age_s < throttle_seconds:
807
898
  return f"skipped(fresh:{int(age_s)}s)", None
808
899
  try:
809
900
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
810
- except RefreshUsageRateLimitError:
901
+ except RefreshUsageRateLimitError as exc:
902
+ # Arm the shared backoff deadline so the next tick is suppressed
903
+ # rather than immediately re-polling (spec §4). Honors Retry-After
904
+ # when present, else conservative exponential backoff.
905
+ c._oauth_backoff_register_429(
906
+ retry_after_deadline=getattr(exc, "retry_after_deadline", None),
907
+ now=time.time(),
908
+ )
811
909
  return "err(rate-limit)", None
812
910
  except RefreshUsageNetworkError:
813
911
  return "err(network)", None
814
912
  except RefreshUsageMalformedError:
815
913
  return "err(parse)", None
914
+ # Successful API response — clear any pending 429 backoff + counter.
915
+ c._oauth_backoff_reset()
816
916
  seven = api["seven_day"]
817
917
  try:
818
918
  seven_pct = c._normalize_percent(float(seven["utilization"]))
@@ -839,6 +939,8 @@ def _hook_tick_oauth_refresh(
839
939
  resets_at=str(seven_resets_epoch),
840
940
  five_hour_percent=five_pct,
841
941
  five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
942
+ # OAuth feeder — label the row "api" (spec §5).
943
+ source="api",
842
944
  )
843
945
  try:
844
946
  rc = c.cmd_record_usage(record_args)
@@ -602,6 +602,7 @@ def cmd_session(args: argparse.Namespace) -> int:
602
602
 
603
603
  def cmd_range_cost(args: argparse.Namespace) -> int:
604
604
  c = _cctally()
605
+ c._share_validate_args(args)
605
606
  # Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
606
607
  # its own — start/end carry their own zone via ISO 8601. Calling the
607
608
  # bridge keeps the alias-surface contract uniform across the 10
@@ -701,7 +702,12 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
701
702
  ),
702
703
  "modelBreakdowns": breakdowns,
703
704
  }
704
- print(json.dumps(c.stamp_schema_version(output), indent=2))
705
+ payload = c.stamp_schema_version(output)
706
+ sink = getattr(args, "_source_result_sink", None)
707
+ if sink is not None:
708
+ sink(payload)
709
+ else:
710
+ print(json.dumps(payload, indent=2))
705
711
  return 0
706
712
 
707
713
  if args.breakdown: