diff --git a/CHANGELOG.md b/CHANGELOG.md index ed27b42..98996ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v0.8.0 — name the wall (2026-09-02) + +Claude Code can now offer `/low-priority` at a spent 5h session window, but +the gated offer, active mode, and separate allowance do not exist in +`/api/oauth/usage`. ccpace now says what its source can prove: +`5h capped · 47% of 7d left · back @Tue 2 04:00`. It distinguishes the +shorter wall from the weekly pool without claiming a session-only escape is +available for an account it cannot see into. + +Notifications now carry the same facts. Their producers and formatter had +drifted onto different key names, causing real threshold and delta messages +to say `0%`; the payload now has canonical `window`, `utilization`, +`reset_at`, and `reset_time` fields while retaining the old aliases for +custom hooks. Custom-notifier envelopes also gain a stable event `id`, such +as `full:work:5h:`, for dedupe and tracing across restarts. + +The old watch cache used the maximum of every counter as an account-level +stop: 5h at 100, or one scoped model at 100, froze the whole account until a +reset. That is false once lower-priority service can bypass 5h, and it was +already false for another model. Worse, the early return ignored a newer +shared cache from statusline and even `r`. The cache is now reserved for +one genuinely terminal state — aggregate 7d spent with no paid path — and +both newer shared evidence and manual refresh evict it. + +85 tests. + ## v0.7.0 — a guess may not delete a window (2026-09-01) **The `×` cell is retired.** The ledger used to overwrite dry-projected diff --git a/DESIGN.md b/DESIGN.md index 69b5ce7..22e41a1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,12 +111,30 @@ the DATE, and the `!` row is where it goes. Two lines describing one week with two numbers is not more information; it is an argument the reader has to settle. +A 5h cap is not a weekly cap. Its quota row says `cap`, never the occasional +endpoint value `101%`; the reset is the useful fact once the counter binds. +The wall row keeps the weekly pool beside it: `5h capped · 47% of 7d left · back +@Tue 2 04:00`. Claude Code may offer `/low-priority` there, but that gated +offer and its allowance are absent from `/api/oauth/usage`; this surface +states the two counters it can prove and does not impersonate session state. + +## Notifications + +The watch surface and its side channels share one vocabulary. Every event +payload names `window`, `utilization`, `reset_at`, and the display-safe +`reset_time`; producers do not invent aliases that formatters have to guess. +The envelope adds a stable, readable `id` (`full:work:5h:`), so a +custom notifier can dedupe or trace one condition across process restarts. +Threshold and delta IDs add the utilization that caused the event; two real +climbs inside one window remain two events. + ## Provenance on the rule -`(cached)` frozen at 100% until reset · `(stale 12m · !429)` last fetch -failed, numbers this old · `(idle 12m)` Claude Code did nothing since, -so nothing was asked. Never a blank block: the last cache beats an empty -frame. +`(cached)` the aggregate weekly pool is spent and no paid path exists, +so this payload cannot move before its 7d reset · `(stale 12m · !429)` +last fetch failed, numbers this old · `(idle 12m)` Claude Code did nothing +since, so nothing was asked. A manual refresh or a newer shared cache always +breaks `(cached)`. Never a blank block: the last cache beats an empty frame. ## Requests @@ -128,6 +146,10 @@ Ask only when the answer can have changed. container sharing `~/.claude`) → no request; `r` overrides. - Reset boundaries wake the loop; the poll interval (15 min ± 10%) is the ceiling, not the metronome. +- A 5h or model-scoped cap never freezes the account: lower-priority service + or another model can keep moving the weekly counters. +- Only 7d at cap with no paid path is terminal until reset. Even then, `r` + and a newer cache from statusline take precedence over the optimization. - Failure: last cache + badge, retry next tick; never a lockout, never `usage.err` (that file is the statusline's). - Profile 24 h (tier comes from the credentials file); prepaid 1 h and diff --git a/README.md b/README.md index e1a9cf4..a405620 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ sorted by tier. - Budget math truncates at the subscription period end (derived from the billing anniversary — the API exposes no cancel/renew date, so the boundary is assumed and marked with `~`). +- A spent 5h window is named separately from the week behind it: + `5h capped · 47% of 7d left · back @Tue 2 04:00`. Current Claude Code + may offer `/low-priority` at that wall, but eligibility and its separate + allowance travel with the session request, not `/api/oauth/usage`. + ccpace therefore reports the proven wall and weekly headroom without + claiming the mode is available for an account it cannot see into. ## Notifications @@ -86,6 +92,11 @@ ccpace --watch --bark # bark CLI env: BARK_KEY on BARK_SE ccpace --watch --notifier ~/bin/my-hook.sh # JSON on stdin ``` +Custom notifier payloads carry a stable, inspectable `id` plus canonical +`window`, `utilization`, `reset_at`, and `reset_time` fields in `data`. +For example, `full:work:5h:2026-09-02T11:00:00+00:00` identifies one +condition across custom-notifier restarts. + Env: `CCPACE_NTFY`, `CCPACE_BARK`, `CCPACE_NOTIFIER`, `CCPACE_INTERVAL`, `CCPACE_THRESHOLD`, `CCPACE_TZ` (e.g. `America/New_York,Asia/Tokyo`). Bare `--bark` reads the bark CLI's own `BARK_KEY` / `BARK_SERVER` @@ -119,6 +130,10 @@ writing; `CCPACE_DATA_DIR` relocates the store. is the ceiling. A failed fetch keeps the last good numbers on screen, badged `(stale 12m · !429)`, and the next poll is the retry — nothing is locked out. +- A 5h or model-scoped cap does not freeze the account: lower-priority + service or another model can still move 7d. Only an exhausted aggregate + week with no paid path is cached to its reset, and both `r` and a newer + shared statusline cache break that optimization. - The grammar — rows, ledger, provenance, requests — is one page: [DESIGN.md](DESIGN.md). - Forecasts are your own history extrapolated, not a promise. Below two diff --git a/ccpace.py b/ccpace.py index 2c2134a..4c2df65 100755 --- a/ccpace.py +++ b/ccpace.py @@ -3,7 +3,7 @@ # requires-python = ">=3.11" # dependencies = ["httpx[socks]"] # /// -# Version: 0.7.0 +# Version: 0.8.0 """ ccpace - pace your Claude quota. Multi-account usage monitor for Claude subscriptions: real utilization from the official usage endpoint, a @@ -49,7 +49,8 @@ --bark [URL] / CCPACE_BARK bark endpoint (/KEY); bare --bark uses BARK_KEY on BARK_SERVER (bark CLI env) --notifier PATH / CCPACE_NOTIFIER custom script, JSON on stdin: - {"event": "threshold|full|delta|pace|reset", "account": "...", "data": {...}} + {"id": "...", "event": "threshold|full|delta|pace|reset", + "account": "...", "data": {...}} """ from __future__ import annotations @@ -77,7 +78,7 @@ import httpx -__version__ = "0.7.0" +__version__ = "0.8.0" CLI_VERSION = "2.1.234" CLIENT_PLATFORM = "claude_code_cli" # anthropic-client-platform for entrypoint=cli API_VERSION = "2023-06-01" @@ -2102,17 +2103,20 @@ def build_advice( now = now or datetime.now(timezone.utc) advice: list[tuple[str, str]] = [] seven_warned = False + seven = next((w for w in windows if w["name"] == "7d"), None) for w in windows: name, util, pace = w["name"], w["util"], w["pace"] if util >= 100: - advice.append( - ( - "warn", - f"{name} capped - resets in {format_duration(int(w['remaining']))} " - f"({format_reset_weekday(w['reset_dt'])})", - ) - ) + parts = [f"{name} capped"] + # A spent session window is not an exhausted week. Claude Code + # may offer /low-priority here, but eligibility and its separate + # allowance do not exist in /api/oauth/usage, so ccpace states + # only what it can prove: the weekly pool still behind the wall. + if name == "5h" and seven and seven["util"] < 100: + parts.append(f"{100 - seven['util']}% of 7d left") + parts.append(f"back {format_reset_weekday(w['reset_dt'])}") + advice.append(("warn", " · ".join(parts))) if name == "7d": seven_warned = True continue @@ -2146,7 +2150,6 @@ def build_advice( # reset, that is the fact worth a warn row: a DATE, not a percentage # above 100. Guarded by the same `seven_warned` flag the pace warning # uses, so the block never carries two walls for one window. - seven = next((w for w in windows if w["name"] == "7d"), None) if seven and projection and projection[1] is not None and not seven_warned: dry = projection[1] gap = (seven["reset_dt"] - dry).total_seconds() @@ -2584,17 +2587,24 @@ def notify_bark(url: str, title: str, message: str, event: str) -> None: def format_notification_message(event: str, account: str, data: dict) -> str: """Format human-readable notification message for system fallback.""" + window = data.get("window") or "quota" + util = data.get("utilization", 0) + reset = data.get("reset_time") or "" + reset_part = f" · back {reset}" if reset else "" if event == "threshold": - util = data.get("utilization", 0) thresh = data.get("threshold", 0) - return f"{account}: {util}% usage (threshold: {thresh}%)" + return f"{account}: {window} {util}% · threshold {thresh}%{reset_part}" if event == "full": - return f"{account}: quota full (100%)" + weekly = data.get("weekly_headroom") + headroom = ( + f" · {weekly}% of 7d left" + if window == "5h" and isinstance(weekly, int) and weekly > 0 + else "" + ) + return f"{account}: {window} capped{headroom}{reset_part}" if event == "delta": - util = data.get("utilization", 0) delta = data.get("delta", 0) - reset = data.get("reset_short", "") - return f"{account}: {util}% (+{delta}%) reset {reset}" + return f"{account}: {window} {util}% (+{delta}%){reset_part}" if event == "pace": window = data.get("window", "") pace = data.get("pace", 0) @@ -2605,6 +2615,21 @@ def format_notification_message(event: str, account: str, data: dict) -> str: return f"{account}: {event}" +def notification_event_id(event: str, account: str, data: dict) -> str: + """Stable, inspectable identity for a notification condition. + + A custom notifier can dedupe `full` or `pace` across restarts by the + window's reset instant. Delta and threshold events add the reading that + caused them, so two real climbs inside one window remain distinct. + """ + window = str(data.get("window") or "quota") + reset = str(data.get("reset_at") or data.get("reset_time") or "unknown") + parts = [event, account or "default", window, reset] + if event in ("delta", "threshold"): + parts.append(str(data.get("utilization", "unknown"))) + return ":".join(parts) + + def send_notification( event: str, account: str, @@ -2634,7 +2659,12 @@ def _send_notification( data: dict, notifier: str | None = None, ) -> None: - payload = {"event": event, "account": account, "data": data} + payload = { + "id": notification_event_id(event, account, data), + "event": event, + "account": account, + "data": data, + } title = f"Claude {event.title()}" message = format_notification_message(event, account, data) @@ -2709,7 +2739,10 @@ def print_window_line( pace = entry["pace"] if entry else None # the number inherits the bar's temperature once it matters; calm # rows keep a plain figure so hot ones actually stand out - util_txt = f"{util:3d}%" + # The endpoint has emitted 101 at the binding wall. Above 100 there is no + # extra reading to communicate: the state is cap and the reset owns the + # next decision. Keep the row's four-column grammar either way. + util_txt = f"{'cap':>4}" if util >= 100 else f"{util:3d}%" if use_color and util >= 80: util_txt = f"{BOLD}{RED}{util_txt}{RESET}" elif use_color and util >= 60: @@ -2870,7 +2903,7 @@ def section_delta(section: str, util: int) -> int: # question (how much of THIS window is left), so none of them gets a # different shape — the ledger below is where the other question lives. five = data.get("five_hour") or {} - five_util = int(five.get("utilization", 0) or 0) + five_util = min(100, int(five.get("utilization", 0) or 0)) entry = by_name.get("5h") if entry: print_window_line( @@ -2884,7 +2917,7 @@ def section_delta(section: str, util: int) -> int: print_no_session_line("5h", use_color) seven = data.get("seven_day") or {} - seven_util = int(seven.get("utilization", 0) or 0) + seven_util = min(100, int(seven.get("utilization", 0) or 0)) seven_entry = by_name.get("7d") if seven_entry: print_window_line( @@ -2920,7 +2953,7 @@ def section_delta(section: str, util: int) -> int: if not scope: continue # session/weekly_all already shown via five_hour/seven_day model = (scope.get("model") or {}).get("display_name") or lim.get("kind") - util = int(lim.get("percent", 0) or 0) + util = min(100, int(lim.get("percent", 0) or 0)) # the model name IS the label; a 2-char tag needed a suffix crutch tag = (model or "??").lower()[:5] print_window_line( @@ -3134,6 +3167,29 @@ def get_max_utilization(data: dict, include_model_specific: bool = False) -> int return max(five_util, seven_util) +def account_is_terminal(data: dict) -> bool: + """Can this account's usage payload remain unchanged until the 7d reset? + + A 5h cap is bypassable when Claude Code offers lower-priority service, + and a scoped cap still leaves other models. Neither is immutable account + state. Only the aggregate weekly pool stops every included path, and even + that is not terminal while paid usage can carry requests beyond it. + """ + seven = data.get("seven_day") or {} + weekly_spent = int(seven.get("utilization", 0) or 0) >= 100 + extra = data.get("extra_usage") or {} + spend = data.get("spend") or {} + paid_path = extra.get("is_enabled") is True or spend.get("enabled") is True + return weekly_spent and not paid_path + + +def terminal_reset(data: dict) -> datetime | None: + """The wall that can make account_is_terminal true.""" + value = (data.get("seven_day") or {}).get("resets_at") + dt = parse_reset_dt(value) + return dt if dt and dt > datetime.now(timezone.utc) else None + + def get_earliest_reset(data: dict) -> datetime | None: """Earliest upcoming reset across every window that can bind the account. @@ -3226,20 +3282,15 @@ def idle_usage(alias: str) -> dict | None: def fetch_or_use_cache( label: str, cred_path: Path, - cached_full_accounts: dict, + terminal_accounts: dict, credential_tokens: dict, now: datetime, trace: bool, ) -> tuple[dict | None, bool, int]: """Fetch usage data or use cache. Returns (data, used_cache, exit_code).""" - if label in cached_full_accounts: - cached_data, cached_reset = cached_full_accounts[label] - if cached_reset is not None and now < cached_reset: - LOGGER.debug("%s: cached", label) - return cached_data, True, EXIT_OK - - LOGGER.debug("%s: reset reached, refreshing", label) - del cached_full_accounts[label] + alias = get_alias_from_label(label) + if cached := terminal_cached_usage(label, alias, terminal_accounts, now): + return cached, True, EXIT_OK refreshed_token = refresh_token_if_needed(cred_path) if refreshed_token: @@ -3257,7 +3308,6 @@ def fetch_or_use_cache( LOGGER.warning("%s: no valid token", label) return None, False, EXIT_RUNTIME - alias = get_alias_from_label(label) state, pooled = pooled_usage(alias) if state == "fresh": LAST_FETCH_AT[label] = float(pooled.get("fetched_at") or datetime.now(timezone.utc).timestamp()) @@ -3283,9 +3333,9 @@ def fetch_or_use_cache( FORCE_FETCH: set[str] = set() # labels whose next cycle must ask (r pressed) -async def fetch_all_with_cache_async( +async def fetch_all_watch_async( credentials: list[tuple[Path, str]], - cached_full_accounts: dict, + terminal_accounts: dict, credential_tokens: dict, now: datetime, trace: bool, @@ -3296,16 +3346,10 @@ async def fetch_all_with_cache_async( async with httpx.AsyncClient(limits=HTTP_LIMITS, timeout=HTTP_TIMEOUT) as client: for cred_path, _ in credentials: label = str(cred_path) - - # Check cache first - if label in cached_full_accounts: - cached_data, cached_reset = cached_full_accounts[label] - if cached_reset is not None and now < cached_reset: - LOGGER.debug("%s: cached", label) - results[label] = (cached_data, True, EXIT_OK) - continue - LOGGER.debug("%s: reset reached, refreshing", label) - del cached_full_accounts[label] + alias = get_alias_from_label(label) + if cached := terminal_cached_usage(label, alias, terminal_accounts, now): + results[label] = (cached, True, EXIT_OK) + continue # Refresh token if needed (sync - local file operation) refreshed_token = refresh_token_if_needed(cred_path) @@ -3323,7 +3367,6 @@ async def fetch_all_with_cache_async( LOGGER.warning("%s: no valid token", label) continue - alias = get_alias_from_label(label) state, pooled = pooled_usage(alias) if state == "fresh": LAST_FETCH_AT[label] = float(pooled.get("fetched_at") or datetime.now(timezone.utc).timestamp()) @@ -3363,29 +3406,47 @@ def handle_notifications( timestamp = timestamp_now() tier = get_account_tier_label(profile) - five = (data.get("five_hour") or {}) if data else {} - seven = (data.get("seven_day") or {}) if data else {} + data = data or {} + five = data.get("five_hour") or {} + seven = data.get("seven_day") or {} five_util = int(five.get("utilization", 0) or 0) seven_util = int(seven.get("utilization", 0) or 0) - window, reset_time = get_smart_reset_info(data) if data else ("", "") + windows = analyze_windows(data, datetime.now(timezone.utc)) + binding = max( + windows, + key=lambda w: (w["util"], bool(w["active"])), + default=None, + ) + window = binding["name"] if binding else "quota" + utilization = min(100, binding["util"] if binding else max_util) + reset_at = binding["reset_dt"].isoformat() if binding else "" + reset_time = format_reset_local(binding["reset_dt"]) if binding else "" + context = { + "timestamp": timestamp, + "tier": tier, + "window": window, + "utilization": utilization, + "reset_at": reset_at, + "reset_time": reset_time, + "five_hour_utilization": five_util, + "seven_day_utilization": seven_util, + "weekly_headroom": max(0, 100 - seven_util), + # Compatibility aliases from <=0.7.0. New consumers should use the + # canonical names above; existing notifier hooks keep working. + "five_util": five_util, + "seven_util": seven_util, + "reset_window": window, + } if max_util >= threshold and label not in notified_threshold: send_notification( "threshold", account, - { - "timestamp": timestamp, - "tier": tier, - "threshold": threshold, - "five_util": five_util, - "seven_util": seven_util, - "reset_window": window, - "reset_time": reset_time, - }, + {**context, "threshold": threshold}, notifier, ) notified_threshold[label] = True - messages.append(("threshold", account, max_util, threshold)) + messages.append(("threshold", account, {**context, "threshold": threshold})) elif max_util < threshold and label in notified_threshold: del notified_threshold[label] @@ -3393,18 +3454,11 @@ def handle_notifications( send_notification( "full", account, - { - "timestamp": timestamp, - "tier": tier, - "five_util": five_util, - "seven_util": seven_util, - "reset_window": window, - "reset_time": reset_time, - }, + context, notifier, ) notified_full[label] = True - messages.append(("full", account, max_util, threshold)) + messages.append(("full", account, context)) elif max_util < 100 and label in notified_full: del notified_full[label] @@ -3699,8 +3753,8 @@ def handle_delta_notification( five = data.get("five_hour") or {} prev_five = prev_data.get("five_hour") or {} - five_util = int(five.get("utilization", 0) or 0) - five_prev = int(prev_five.get("utilization", 0) or 0) + five_util = min(100, int(five.get("utilization", 0) or 0)) + five_prev = min(100, int(prev_five.get("utilization", 0) or 0)) five_delta = five_util - five_prev if five_delta <= 0: @@ -3712,9 +3766,9 @@ def handle_delta_notification( seven_prev = int(prev_seven.get("utilization", 0) or 0) account = get_alias_from_label(label) - window, reset_time = get_smart_reset_info(data) timestamp = timestamp_now() tier = get_account_tier_label(profile) + reset_dt = parse_reset_dt(five.get("resets_at")) send_notification( "delta", @@ -3722,13 +3776,22 @@ def handle_delta_notification( { "timestamp": timestamp, "tier": tier, + "window": "5h", + "utilization": five_util, + "previous_utilization": five_prev, + "delta": five_delta, + "reset_at": reset_dt.isoformat() if reset_dt else "", + "reset_time": format_reset_local(reset_dt) if reset_dt else "", + "seven_day_utilization": seven_util, + "previous_seven_day_utilization": seven_prev, + "weekly_headroom": max(0, 100 - seven_util), + # Compatibility aliases from <=0.7.0. "five_util": five_util, "five_prev": five_prev, "five_delta": five_delta, "seven_util": seven_util, "seven_prev": seven_prev, - "reset_window": window, - "reset_time": reset_time, + "reset_window": "5h", }, notifier, ) @@ -3772,6 +3835,7 @@ def handle_pace_notifications( "utilization": w["util"], "pace": round(pace, 2), "cap_eta": format_cap_eta(w["cap_eta"], short), + "reset_at": reset_iso, "reset_time": format_reset_local(w["reset_dt"]), }, notifier, @@ -3810,8 +3874,8 @@ def log_usage_jsonl( The shape is claude-code-statusline's record — typed, epoch timestamp, raw API sections verbatim — so both tools share one - history. Cached (100%-cap) responses are not logged: the log - records observations, not echoes. + history. Terminal weekly snapshots and idle responses are not logged: + the log records observations, not echoes. """ if cached or usage.get("_from_shared_cache"): return @@ -3862,25 +3926,65 @@ def log_usage_jsonl( LOGGER.debug("failed to write usage log: %s", e) -def update_cache( +def terminal_cached_usage( + label: str, + alias: str, + terminal_accounts: dict, + now: datetime, +) -> dict | None: + """A terminal snapshot, unless the human or a cooperating writer moved it. + + Manual refresh always evicts the snapshot. Before serving it, read the + shared pool at any age: statusline may have published a newer response + after paid usage was enabled or a support-side reset changed the account. + An optimization may skip a request; it may never hide newer evidence. + """ + if label not in terminal_accounts: + return None + if label in FORCE_FETCH: + terminal_accounts.pop(label, None) + return None + + cached_data, cached_reset = terminal_accounts[label] + shared = read_shared_usage_cache(alias, max_age=None) + cached_at = int(cached_data.get("fetched_at", 0) or 0) + shared_at = int((shared or {}).get("fetched_at", 0) or 0) + if shared and shared_at > cached_at: + if account_is_terminal(shared): + terminal_accounts[label] = (shared, terminal_reset(shared)) + else: + terminal_accounts.pop(label, None) + LAST_FETCH_AT[label] = float(shared_at) + LAST_RESET[label] = get_earliest_reset(shared) + LOGGER.debug("%s: newer shared cache replaced terminal snapshot", label) + return shared + + if cached_reset is not None and now < cached_reset: + LOGGER.debug("%s: weekly pool terminal until reset", label) + return cached_data + + LOGGER.debug("%s: terminal reset reached, refreshing", label) + terminal_accounts.pop(label, None) + return None + + +def update_terminal_cache( label: str, - max_util: int, data: dict, - cached_full_accounts: dict, + terminal_accounts: dict, ) -> None: - """Update cache for accounts at 100%.""" - if max_util < 100: - if label in cached_full_accounts: - del cached_full_accounts[label] - else: - reset_time = get_earliest_reset(data) - cached_full_accounts[label] = (data, reset_time) - if reset_time: - LOGGER.debug( - "%s: cached until %s", - label, - reset_time.astimezone().strftime("%m/%d %H:%M:%S"), - ) + """Cache only an aggregate weekly wall with no paid path around it.""" + if not account_is_terminal(data): + terminal_accounts.pop(label, None) + return + reset_time = terminal_reset(data) + if reset_time: + terminal_accounts[label] = (data, reset_time) + LOGGER.debug( + "%s: weekly pool terminal until %s", + label, + reset_time.astimezone().strftime("%m/%d %H:%M:%S"), + ) def jittered(interval: int) -> int: @@ -3961,10 +4065,10 @@ def countdown_sleep( def print_watch_footer( - all_full: bool, + all_terminal: bool, earliest_global_reset: datetime | None, earliest_reset_account: str | None, - cached_count: int, + terminal_count: int, total_count: int, interval: int, notifier: str | None, @@ -3983,23 +4087,23 @@ def print_watch_footer( print() now = datetime.now(timezone.utc) - if cached_count > 0: + if terminal_count > 0: if use_color: print( - f"{DIM}Cached: {cached_count}/{total_count} at 100% (no API polling){RESET}" + f"{DIM}Weekly cap: {terminal_count}/{total_count} without a paid path{RESET}" ) else: - print(f"Cached: {cached_count}/{total_count} at 100% (no API polling)") + print(f"Weekly cap: {terminal_count}/{total_count} without a paid path") - if all_full and earliest_global_reset: + if all_terminal and earliest_global_reset: remaining = int((earliest_global_reset - now).total_seconds()) reset_local = format_multi_tz(earliest_global_reset) symbol = "⏳" if supports_unicode() else "~" if use_color: - print(f"{YELLOW}{symbol} All quotas full. Next reset: {reset_local}{RESET}") + print(f"{YELLOW}{symbol} All weekly pools spent. Next reset: {reset_local}{RESET}") else: - print(f"{symbol} All quotas full. Next reset: {reset_local}") + print(f"{symbol} All weekly pools spent. Next reset: {reset_local}") sleep_time = min(WAIT_MODE_CHECK_INTERVAL, remaining) status = countdown_sleep( @@ -4066,7 +4170,7 @@ def info_usage_watch( notified_threshold = {} notified_full = {} notified_pace = {} - cached_full_accounts = {} + terminal_accounts = {} credential_tokens = {str(path): token for path, token in credentials} previous_data = {} fail_backoff = 0 # exponential, resets on any successful cycle @@ -4121,7 +4225,8 @@ def sort_by_tier(creds): move_cursor_home() now = datetime.now(timezone.utc) - all_full = True + all_terminal = True + terminal_count = 0 earliest_global_reset = None earliest_reset_account = None success_count = 0 @@ -4140,9 +4245,9 @@ def sort_by_tier(creds): # Fetch all credentials concurrently if len(credentials) > 1: fetch_results = asyncio.run( - fetch_all_with_cache_async( + fetch_all_watch_async( credentials, - cached_full_accounts, + terminal_accounts, credential_tokens, now, trace, @@ -4155,7 +4260,7 @@ def sort_by_tier(creds): data, used_cache, code = fetch_or_use_cache( label, cred_path, - cached_full_accounts, + terminal_accounts, credential_tokens, now, trace, @@ -4169,10 +4274,12 @@ def sort_by_tier(creds): data, used_cache, code = fetch_results.get(label, (None, False, EXIT_RUNTIME)) if code != EXIT_OK: + all_terminal = False failed_accounts.append(display_alias(label)) continue if not data: + all_terminal = False continue success_count += 1 @@ -4193,15 +4300,16 @@ def sort_by_tier(creds): if not used_cache: previous_data[label] = data - # include model-scoped limits: an account blocked on the - # fable/opus weekly is full even when 5h/7d-all sit low, and - # must be cached + reset-watched like any other full window. + # Threshold/full notices are limit-specific. Terminal ACCOUNT + # state is narrower: only the aggregate week with no paid path + # can make the whole payload immutable until reset. max_util = get_max_utilization(data, include_model_specific=True) - - if max_util < 100: - all_full = False - - update_cache(label, max_util, data, cached_full_accounts) + terminal = account_is_terminal(data) + if terminal: + terminal_count += 1 + else: + all_terminal = False + update_terminal_cache(label, data, terminal_accounts) msgs = handle_notifications( label, @@ -4221,7 +4329,7 @@ def sort_by_tier(creds): label, data, notified_pace, notifier, profile ) - reset_time = get_earliest_reset(data) + reset_time = terminal_reset(data) if terminal else get_earliest_reset(data) if reset_time and ( not earliest_global_reset or reset_time < earliest_global_reset ): @@ -4231,19 +4339,15 @@ def sort_by_tier(creds): if notification_messages: print() symbol = "⚠" if supports_unicode() else "!" - for ntype, account, util, thresh in notification_messages: - if ntype == "threshold": - if use_color: - print( - f"{YELLOW}{symbol} {account}: {util}% (threshold: {thresh}%){RESET}" - ) - else: - print(f"{symbol} {account}: {util}% (threshold: {thresh}%)") - elif ntype == "full": - if use_color: - print(f"{RED}{symbol} {account}: quota full{RESET}") - else: - print(f"{symbol} {account}: quota full") + for ntype, account, event_data in notification_messages: + message = format_notification_message(ntype, account, event_data) + color = RED if ntype == "full" else YELLOW + shown = ( + f"{color}{symbol} {message}{RESET}" + if use_color + else f"{symbol} {message}" + ) + print(shown) if failed_accounts: print() @@ -4254,8 +4358,8 @@ def sort_by_tier(creds): else: print(f"{symbol} Failed: {failed_str}") - if success_count == 0 and len(cached_full_accounts) == 0: - # Nothing to show and nothing cached: doubling local backoff + if success_count == 0: + # Nothing to show: doubling local backoff # from BACKOFF_BASE_SEC, stretched to a Retry-After when the # API sent one, but never past the poll interval — the next # tick is the retry, and one request per tick cannot hurt. @@ -4280,10 +4384,10 @@ def sort_by_tier(creds): fail_backoff = 0 footer_status = print_watch_footer( - all_full, + all_terminal, earliest_global_reset, earliest_reset_account, - len(cached_full_accounts), + terminal_count, len(credentials), jittered(interval), notifier, diff --git a/docs/data.md b/docs/data.md index 5d2d9de..fe29a2b 100644 --- a/docs/data.md +++ b/docs/data.md @@ -97,9 +97,8 @@ types (forward compatibility). `timestamp` is unix epoch seconds (int). } ccpace additions are additive only (`source`); it never renames or -re-nests statusline fields. `cached` responses (served from the -100%-cap cache) are NOT logged — the log records observations, not -echoes. +re-nests statusline fields. Terminal weekly snapshots and idle responses +served from cache are NOT logged — the log records observations, not echoes. ### type: "session_start" / "session_end" — statusline's markers @@ -191,13 +190,23 @@ pool. Whichever tool fetched last serves both: is shown at any age with a `(stale · !429)` badge and the next poll is the retry. +Lower-priority mode is deliberately not part of this store. Its offer, +active state, and separate allowance are session/request facts that are not +present in `/api/oauth/usage`. A consumer may distinguish a capped 5h window +from remaining `seven_day` headroom, but must not turn that relation into a +claim that `/low-priority` is available. + ## Fetch discipline (rate-limit hygiene) Defaults chosen so a fleet of watchers stays invisible to the API: - usage poll: 900 s default, minimum 60 s enforced, ±10% jitter per cycle (fleet watchers must not synchronize). -- accounts at 100%: no polling until the earliest reset (cap cache). +- A 5h or model-scoped cap never suppresses polling: lower-priority service + or another model can still move the account. +- An aggregate 7d cap with no paid path is terminal until its own reset. + The in-memory snapshot yields to `r` and to any newer `usage.cache` + published by statusline, so the optimization never hides fresh evidence. - any fetch failure (429, 5xx, transport): show the last cache badged stale, retry on the next tick. With nothing to show at all: doubling backoff from 60 s, stretched to `Retry-After` when sent, never past diff --git a/pyproject.toml b/pyproject.toml index 5a1e193..e25b053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ccpace" -version = "0.7.0" +version = "0.8.0" description = "Pace your Claude quota: multi-account usage monitor with a countable 5h-window budget, weekday forecasts, and push notifications" readme = "README.md" license = { text = "MIT" } diff --git a/t/test_advice.py b/t/test_advice.py index 5ec2ba0..a2c0ee3 100644 --- a/t/test_advice.py +++ b/t/test_advice.py @@ -340,7 +340,7 @@ def test_access_end_truncates_the_budget_and_mutes_the_landing(utc_now): def test_a_capped_week_says_when_it_comes_back(utc_now): seven = seven_entry(100, 2 * DAY, utc_now) (warn,) = warns_of(advice(utc_now, [seven])) - assert warn.startswith("7d capped - resets in") + assert warn.startswith("7d capped · back @") def test_hot_segments_stop_the_eye_only_near_the_cap(utc_now): diff --git a/t/test_notifications.py b/t/test_notifications.py new file mode 100644 index 0000000..67ae800 --- /dev/null +++ b/t/test_notifications.py @@ -0,0 +1,139 @@ +"""Notification contracts: the message and machine payload tell one story.""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from conftest import cc, five_entry, seven_entry + + +def test_spent_row_says_cap_not_101_percent(capsys): + cc.print_window_line("5h", 101, False) + row = capsys.readouterr().out + assert " cap " in row + assert "101%" not in row + + +def test_threshold_payload_names_the_binding_window(monkeypatch): + now = datetime.now(timezone.utc) + reset_5h = (now + timedelta(hours=2)).isoformat() + reset_7d = (now + timedelta(days=3)).isoformat() + data = { + "five_hour": {"utilization": 86, "resets_at": reset_5h}, + "seven_day": {"utilization": 53, "resets_at": reset_7d}, + } + sent = [] + monkeypatch.setattr( + cc, + "send_notification", + lambda event, account, payload, notifier: sent.append( + (event, account, payload) + ), + ) + + messages = cc.handle_notifications( + "work.credentials.json", 86, 80, {}, {}, None, data + ) + + event, account, payload = sent[0] + assert (event, account) == ("threshold", "work") + assert payload["window"] == "5h" + assert payload["utilization"] == 86 + assert payload["weekly_headroom"] == 47 + assert payload["reset_at"] == reset_5h + assert cc.format_notification_message(event, account, payload).startswith( + "work: 5h 86% · threshold 80% · back " + ) + assert messages == [("threshold", "work", payload)] + + +def test_full_session_message_keeps_weekly_headroom_visible(monkeypatch): + now = datetime.now(timezone.utc) + data = { + "five_hour": { + "utilization": 101, + "resets_at": (now + timedelta(hours=2)).isoformat(), + }, + "seven_day": { + "utilization": 53, + "resets_at": (now + timedelta(days=3)).isoformat(), + }, + } + sent = [] + monkeypatch.setattr( + cc, + "send_notification", + lambda event, account, payload, notifier: sent.append( + (event, account, payload) + ), + ) + + cc.handle_notifications("work.credentials.json", 101, 80, {}, {}, None, data) + + event, account, payload = next(item for item in sent if item[0] == "full") + assert cc.format_notification_message(event, account, payload).startswith( + "work: 5h capped · 47% of 7d left · back " + ) + assert cc.notification_event_id(event, account, payload).startswith("full:work:5h:") + + +def test_delta_producer_and_formatter_use_the_same_keys(monkeypatch): + now = datetime.now(timezone.utc) + reset = (now + timedelta(hours=2)).isoformat() + data = { + "five_hour": {"utilization": 86, "resets_at": reset}, + "seven_day": {"utilization": 53}, + } + previous = {"five_hour": {"utilization": 80}, "seven_day": {"utilization": 52}} + sent = [] + monkeypatch.setattr( + cc, + "send_notification", + lambda event, account, payload, notifier: sent.append( + (event, account, payload) + ), + ) + + cc.handle_delta_notification("work.credentials.json", data, previous, None) + + event, account, payload = sent[0] + assert payload["window"] == "5h" + assert payload["utilization"] == 86 + assert payload["delta"] == 6 + assert cc.format_notification_message(event, account, payload).startswith( + "work: 5h 86% (+6%) · back " + ) + assert cc.notification_event_id(event, account, payload).endswith(":86") + + +def test_custom_notifier_envelope_carries_the_event_id(monkeypatch): + delivered = [] + monkeypatch.setattr(cc, "NOTIFY_CHANNELS", {}) + monkeypatch.setattr(cc, "validate_executable", lambda _: Path("/notifier")) + monkeypatch.setattr( + cc, + "run_notifier", + lambda args, input_data, timeout: ( + delivered.append(json.loads(input_data)) or True + ), + ) + data = { + "window": "5h", + "utilization": 100, + "reset_at": "2026-09-02T11:00:00+00:00", + } + + cc._send_notification("full", "work", data, "/notifier") + + assert delivered[0]["id"] == "full:work:5h:2026-09-02T11:00:00+00:00" + assert delivered[0]["data"] == data + + +def test_capped_advice_distinguishes_session_from_week(utc_now): + five = five_entry(100, 2 * 3600, utc_now) + seven = seven_entry(53, 3 * 86400, utc_now) + + advice = cc.build_advice({}, [five, seven], now=utc_now) + + assert advice[0][0] == "warn" + assert advice[0][1].startswith("5h capped · 47% of 7d left · back @") diff --git a/t/test_polling.py b/t/test_polling.py new file mode 100644 index 0000000..4269998 --- /dev/null +++ b/t/test_polling.py @@ -0,0 +1,92 @@ +"""Watch polling: an optimization may never freeze a still-moving account.""" + +from datetime import datetime, timedelta, timezone + +from conftest import cc + + +def usage( + *, + five: int = 20, + seven: int = 40, + scoped: int | None = None, + extra: bool = False, +) -> dict: + reset_5h = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat() + reset_7d = (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() + limits = [] + if scoped is not None: + limits.append( + { + "kind": "weekly_scoped", + "percent": scoped, + "resets_at": reset_7d, + "scope": {"model": {"display_name": "Fable"}}, + } + ) + return { + "five_hour": {"utilization": five, "resets_at": reset_5h}, + "seven_day": {"utilization": seven, "resets_at": reset_7d}, + "limits": limits, + "extra_usage": {"is_enabled": extra}, + } + + +def test_only_an_unpaid_weekly_wall_is_terminal(): + assert not cc.account_is_terminal(usage(five=100, seven=53)) + assert not cc.account_is_terminal(usage(seven=53, scoped=100)) + assert cc.account_is_terminal(usage(seven=100)) + assert not cc.account_is_terminal(usage(seven=100, extra=True)) + + +def test_five_hour_and_scoped_caps_never_freeze_the_account(): + terminal = {} + cc.update_terminal_cache("work", usage(five=100, seven=53), terminal) + assert terminal == {} + cc.update_terminal_cache("work", usage(seven=53, scoped=100), terminal) + assert terminal == {} + + +def test_weekly_wall_is_cached_until_its_own_reset(store): + payload = usage(seven=100) + terminal = {} + cc.update_terminal_cache("work", payload, terminal) + + assert "work" in terminal + cached = cc.terminal_cached_usage( + "work", "work", terminal, datetime.now(timezone.utc) + ) + assert cached is payload + + +def test_manual_refresh_evicts_a_terminal_snapshot(): + label = "work.credentials.json" + terminal = {} + cc.update_terminal_cache(label, usage(seven=100), terminal) + cc.FORCE_FETCH.add(label) + try: + assert ( + cc.terminal_cached_usage( + label, "work", terminal, datetime.now(timezone.utc) + ) + is None + ) + assert label not in terminal + finally: + cc.FORCE_FETCH.discard(label) + + +def test_newer_shared_writer_breaks_the_terminal_snapshot(store): + label = "work.credentials.json" + old = usage(seven=100) + old["fetched_at"] = 1 + terminal = {} + cc.update_terminal_cache(label, old, terminal) + + moved = usage(five=100, seven=54, extra=True) + cc.write_shared_usage_cache("work", moved) + got = cc.terminal_cached_usage(label, "work", terminal, datetime.now(timezone.utc)) + + assert got["seven_day"]["utilization"] == 54 + assert got["extra_usage"]["is_enabled"] is True + assert label not in terminal diff --git a/uv.lock b/uv.lock index b0333f8..d2628a3 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "ccpace" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] },