From 55f5a8f8fd0ef4c2eb2767446970a5bfe6e9bd4d Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 2 Sep 2026 01:09:03 -0700 Subject: [PATCH 1/2] feat(notice): name the session wall and low-priority escape --- CHANGELOG.md | 17 +++++++ DESIGN.md | 14 ++++++ README.md | 6 +++ docs/api/oauth-usage.md | 3 ++ docs/api/state-dir.md | 2 +- llms.txt | 12 ++--- skills/usage-insight/SKILL.md | 5 ++ statusline.sh | 94 ++++++++++++++++++++++++++++++++--- t/helpers.bash | 2 +- t/statusline.bats | 48 +++++++++++++++++- 10 files changed, 186 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2417931..f5acd27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased — a session wall is not a weekly wall + +Claude Code 2.1.258 can offer `/low-priority` after the 5h session window +binds, letting that session continue against the weekly pool while requests +wait for spare capacity. The statusline used to render the wall as +`5h[101%]` and then pin a weekly-surplus notice, which made an interrupted +session look like an invitation to spend. + +The badge now says `5h[cap@04:00]`, and a capped 5h notice outranks weekly +opportunity. `/low-priority` is named only when the current transcript's +`quotaLimits.lowPriorityOffer` proves Claude Code offered it for this exact +window; the local-command result distinguishes offered from active. Neither +statusline stdin nor `/api/oauth/usage` exposes the separate lower-priority +allowance, so no allowance percentage is guessed. A fresh sibling account +still outranks the bypass because it avoids the queue and preserves this +account's week. + ## v0.40.0 — 2026-09-01 — what a cold cache costs **The bar's extra color above 200k was a pricing claim, and the price is diff --git a/DESIGN.md b/DESIGN.md index 31f5886..43f68c7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -164,6 +164,11 @@ What the readers know, beyond the badges: ``` fb capped ~Thu 07:00 the running model is gone until then +5h capped · /low-priority uses 7d + this session wall can be bypassed, and this session's + transcript proves Claude Code offered the mode +lower priority · 47% of 7d left + the bypass is active; the weekly pool is now the budget 5h caps ~05:18 this sitting hits the wall before its reset 7d dry ~Thu 09:00 the learned weekday forecast, not a straight line 7d rebased 53%→12% utilization fell INSIDE one window: a plan change or @@ -181,6 +186,15 @@ fb ~22% expires the ACCOUNT caps first: at this week's mix the 7d pool ~62% will expire on pace to strand a large chunk of the subscription ``` +At the 5h wall the badge says `5h[cap@04:00]`, never `100%` or `101%`: +the percentage has stopped being a reading and the reset is the useful fact. +`/low-priority` is never inferred from weekly headroom. Its eligibility is +gated per session and absent from both statusline stdin and `/api/oauth/usage`; +only a matching `quotaLimits.lowPriorityOffer` in this session's transcript +allows the notice to name it. The transcript's local-command result then +distinguishes offered from active. The separate lower-priority allowance is +not exposed there either, so this statusline does not manufacture one. + The budget line is the one sentence with three clauses, and each answers a different question: diff --git a/README.md b/README.md index 1e97284..38061dc 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,8 @@ clauses about one window can never disagree. | Notice | What it knows that line 1 doesn't | |--------|-----------------------------------| | `! fb capped ~Thu 07:00` | The weekly limit scoped to *this session's model* hit 100%: that model is gone until then. The long form names a model that still has room. | +| `! 5h capped · /low-priority uses 7d` | The shorter session window bound while the weekly pool still has room, and this session's transcript says Claude Code actually offered lower-priority mode. The statusline does not infer eligibility from headroom. | +| `+ lower priority · 47% of 7d left` | This session enabled the bypass. Requests may wait for spare capacity and now draw against the weekly pool; the lower-priority allowance itself is not exposed to statusline scripts, so no allowance percentage is guessed. | | `! 5h caps ~05:18` | Linear projection off this window's own pace: you hit the wall before the reset. Long form adds how long you'd sit blocked. | | `! 7d dry ~Thu 09:00 · hard stop` | The learned per-weekday burn profile (EWMA over your own history), not a straight line — your heavy Tuesday counts more than an average, and the learned hour-of-day shape puts the dry point where you are awake to act on it instead of at 03:00. Tail says what happens at 100%: extra billing, or a stop. | | `! fb 91% vs 7d 55% · go op` | **The relation** between two badges: the *model* caps before the account does. Switching models buys the week's remaining capacity back; the roomiest other `weekly_scoped` limit in the payload gets named. | @@ -412,6 +414,10 @@ clauses about one window can never disagree. and `--week` print the long form, since a terminal command has a whole line to spend. +At a spent session window, line 1 renders `5h[cap@04:00]` rather than +`5h[100%]` or the occasional API value `5h[101%]`. Once the window binds, +`cap` is the state and the reset is the number to plan around. + ## The waste ledger The advisor prevents waste prospectively; `report` proves it diff --git a/docs/api/oauth-usage.md b/docs/api/oauth-usage.md index ba9cb15..60f40dc 100644 --- a/docs/api/oauth-usage.md +++ b/docs/api/oauth-usage.md @@ -158,6 +158,9 @@ does that automatically (`curl_ca_bundle` in `statusline.sh`). **`five_hour` / `seven_day`** — the two account-wide windows. `utilization` is a float percentage; `resets_at` is ISO-8601 with microseconds and offset. The `*_dollars` fields have only been observed `null` on subscription plans. +`five_hour.utilization` has been observed at `101.0` when the session window +binds; it is a server reading, not a bounded progress-bar value. Renderers in +this repository treat any value at or above 100 as `cap` and keep the reset. **`limits[]`** — the newer, generic limit contract (this is what the statusline parses first): diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index b49e692..a9e18a4 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -88,7 +88,7 @@ The raw `/api/oauth/usage` response (see `oauth-usage.md`) plus one field: | Field | Type | Meaning | |-------|------|---------| | `fetched_at` | int epoch s | When this fetch succeeded | -| `five_hour.utilization` | number 0–100 | 5h window used % | +| `five_hour.utilization` | number, 0+ | 5h window used %. The API has emitted 101 at the binding wall; consumers should treat >=100 as capped, not as extra precision. | | `five_hour.resets_at` | ISO-8601 | 5h window end | | `seven_day.*` | same | 7d window | | `extra_usage.*` | object | overage spend (see oauth-usage.md) | diff --git a/llms.txt b/llms.txt index 617e87b..fa81502 100644 --- a/llms.txt +++ b/llms.txt @@ -5,14 +5,14 @@ > health, and git activity in Claude Code's statusLine hook — plus a > week row (this 5h window as five hour cells, the 7d period as its 5h > windows with the future folded to a counted `...▯(✕28)` tail: where the -> points went), an advisor row that interprets the -> numbers (cap projections, expiring-surplus and underuse advice, fleet -> relief), and a `report` subcommand that ledgers what each closed window -> expired unused. 435 bats tests, deps are jq + curl, no daemon, no +> points went), a notice engine that pins the top condition and briefly +> explains the next (cap projections, expiring-surplus and underuse advice, +> fleet relief), and a `report` subcommand that ledgers what each closed +> window expired unused. 489 bats tests, deps are jq + curl, no daemon, no > telemetry. Key facts for agents: -- Everything lives in `statusline.sh` (~5300 lines, plain bash). Install +- Everything lives in `statusline.sh` (~6000 lines, plain bash). Install copies it to `~/.claude/statusline.sh`, copies the usage-insight skill to `~/.claude/skills/`, and wires `settings.json` (keeping any flags already on the command). `install.sh` is the one installer for both entrypoints: @@ -45,7 +45,7 @@ Key facts for agents: - [statusline.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/statusline.sh): the entire product - [install.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/install.sh): installer (curl | bash, or STATUSLINE_SRC=) - [Makefile](https://github.com/thevibeworks/claude-code-statusline/blob/main/Makefile): install / status / diff / uninstall / check -- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 417 statusline tests +- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 471 statusline tests - [t/install.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/install.bats): 18 installer tests ## Optional diff --git a/skills/usage-insight/SKILL.md b/skills/usage-insight/SKILL.md index ce66898..d670e6f 100644 --- a/skills/usage-insight/SKILL.md +++ b/skills/usage-insight/SKILL.md @@ -80,6 +80,11 @@ the deep-dive version of what the advisor row says in one sentence. the useful fact is its `resets_at`, not the percentage. - 5h pressure with a reset < 30min away is self-healing — say "resets in N minutes" instead of alarming. +- A capped 5h window is the shorter session wall, not an exhausted week. + If the live statusline says `/low-priority` was offered or is active, + explain that it continues against the remaining 7d pool while requests + wait for spare capacity. The state dir alone cannot prove eligibility or + see the separate lower-priority allowance, so never invent either. - Recommending another account: only name siblings whose cache is fresh (< 1h); an idle-looking stale account is unknown, not free. diff --git a/statusline.sh b/statusline.sh index 35f7317..5be3084 100755 --- a/statusline.sh +++ b/statusline.sh @@ -3914,9 +3914,17 @@ build_usage_display() { # Bump flash: reverse-video +N right after the badge — bound tight so # it can't read as belonging to the next badge, unmissable without # adding a fourth color lane. ASCII + (a ▲ glyph rendered poorly). - local bump_part="" - [ "$five_bump" -gt 0 ] 2>/dev/null && bump_part="${color}${REVERSE}+${five_bump}${NO_REVERSE}" - parts+=("${DIM}5h${color}[${five_int}%${reset_suffix}]${bump_part}${RESET}") + local bump_part="" five_label="${five_int}%" + # The API occasionally reports 101 at the wall. That is not a useful + # precision claim: once the session window binds, the state is CAP and + # the number to plan around is its reset. A +N flash beside `cap` would + # be equally meaningless, so the climb stops speaking there too. + if [ "$five_int" -ge 100 ] 2>/dev/null; then + five_label="cap" + elif [ "$five_bump" -gt 0 ] 2>/dev/null; then + bump_part="${color}${REVERSE}+${five_bump}${NO_REVERSE}" + fi + parts+=("${DIM}5h${color}[${five_label}${reset_suffix}]${bump_part}${RESET}") fi # 7d aggregate quota (if present and >0). Color comes from PACE, not level: @@ -4206,6 +4214,43 @@ build_advisor_fleet_hint() { # field after it (a notice with no highlight token lost its long form). NOTICE_FS=$'\037' NOTICE_RECS=() + +# What Claude Code knows about lower-priority mode for THIS session window. +# The usage endpoint has no offer/allowance field and statusline stdin carries +# only 5h/7d, so neither is allowed to imply availability. The transcript does +# carry the server's gated offer on the rejected assistant record and the local +# command's enable/disable result. Read only the tail: the event is necessarily +# recent when the current 5h window is capped, and a full transcript scan on +# every render would make the statusline cost grow with the conversation. +# +# Echoes `offered`, `active`, or nothing. The reset match is the provenance +# gate: an offer from the previous 5h window cannot advise this one. +transcript_low_priority_state() { + local transcript="$1" reset_ts="$2" + [ -f "$transcript" ] && [ -n "$reset_ts" ] || return 0 + local reset_epoch + reset_epoch=$(_epoch_from_ts "$reset_ts") + [ -n "$reset_epoch" ] || return 0 + tail -300 "$transcript" 2>/dev/null | jq -rs --argjson reset "$reset_epoch" ' + ([.[] | select( + .quotaLimits.lowPriorityOffer? == "treatment" + and .quotaLimits.rateLimitType? == "five_hour" + and (((.quotaLimits.resetsAt? // 0) - $reset) | fabs) <= 120 + )] | sort_by(.timestamp // "") | last) as $offer | + if $offer == null then empty else + ([.[] | select( + .type? == "system" and .subtype? == "local_command" + and ((.content? // "") | type) == "string" + and ((.timestamp // "") >= ($offer.timestamp // "")) + and ((.content // "") | test("lower.priority|low-priority"; "i")) + ) | {at:(.timestamp // ""), + state:(if ((.content // "") | test("Continuing now at lower priority|Lower-priority mode is back on"; "i")) + then "active" else "" end)}] + | sort_by(.at) | last | .state) // "offered" + end + ' 2>/dev/null +} + notice_add() { NOTICE_RECS+=("$1$NOTICE_FS$2$NOTICE_FS$3$NOTICE_FS$4$NOTICE_FS$5$NOTICE_FS$6$NOTICE_FS$7") } @@ -4335,12 +4380,13 @@ notice_collect() { [ "$mode" = "off" ] && return 0 [ -n "$usage_data" ] || return 0 - local five_util five_reset seven_util seven_reset extra_enabled + local five_util five_reset seven_util seven_reset seven_present extra_enabled eval "$(echo "$usage_data" | jq -r ' @sh "five_util=\(.five_hour.utilization // 0)", @sh "five_reset=\(.five_hour.resets_at // "")", @sh "seven_util=\(.seven_day.utilization // 0)", @sh "seven_reset=\(.seven_day.resets_at // "")", + @sh "seven_present=\(.seven_day.utilization != null)", @sh "extra_enabled=\(.extra_usage.is_enabled // false)" ' 2>/dev/null)" @@ -4430,9 +4476,41 @@ notice_collect() { fi fi + # A spent 5h window is a wall, not merely a high reading. Claude Code can + # now offer /low-priority to bypass this SESSION wall against the weekly + # pool, but the offer is gated and its separate allowance is not exposed + # to either statusline input or /api/oauth/usage. Name it only when this + # session's transcript proves the offer. If the local command enabled the + # mode, the wall is resolved and the remaining weekly pool is the fact. + # A fresh sibling account ranks above both later: it avoids the queue and + # preserves this account's weekly pool. + if [ "$five_int" -ge 100 ] && [ -n "$five_secs" ] && [ "$five_secs" -gt 0 ] 2>/dev/null; then + local five_back lowpri_state="" + five_back=$(_fmt_epoch $((now + five_secs)) '%H:%M') + [ -n "${transcript_path:-}" ] \ + && lowpri_state=$(transcript_low_priority_state "$transcript_path" "$five_reset") + if [ -n "$five_back" ] && [ "$seven_present" = "true" ] \ + && [ "$lowpri_state" = "active" ] && [ "$seven_int" -lt 100 ] 2>/dev/null; then + notice_add 70 '+' 5h "5h.lowpri.active.${five_back}" "${surplus}%" \ + "lower priority · ${surplus}% of 7d left" \ + "lower-priority mode bypasses the 5h cap until ${five_back} · ${surplus}% of the weekly pool remains" + elif [ -n "$five_back" ] && [ "$seven_present" = "true" ] \ + && [ "$lowpri_state" = "offered" ] && [ "$seven_int" -lt 100 ] 2>/dev/null; then + notice_add 96 '!yellow' 5h "5h.capped.lowpri.${five_back}" "/low-priority" \ + "5h capped · /low-priority uses 7d" \ + "5h capped until ${five_back} · /low-priority continues now against the ${surplus}% left on 7d" + pressure=1 + elif [ -n "$five_back" ]; then + notice_add 96 '!red' 5h "5h.capped.${five_back}" "~${five_back}" \ + "5h capped · back ~${five_back}" \ + "5h capped until ${five_back}" + pressure=1 + fi + fi + # 5h cap projection. Gate = the 5h badge's own pressure gate (>= 80), - # recovery-suppressed exactly like its colour. At 100% line 1 already - # says capped; only the fleet hint still helps there. + # recovery-suppressed exactly like its colour. At 100% the wall reader + # above owns the scope. if [ "$five_int" -ge 80 ] && [ "$five_int" -lt 100 ] \ && [ -n "$five_secs" ] && [ "$five_secs" -gt "$FIVE_HOUR_RECOVERY_SECS" ] 2>/dev/null; then local elapsed=$((18000 - five_secs)) floor @@ -4703,7 +4781,9 @@ notice_collect() { local hint hint=$(build_advisor_fleet_hint "${STATUSLINE_HOME}/accounts" "$ACCOUNT_TAG") if [ -n "$hint" ]; then - notice_add 60 '+' acct "acct.fleet.${hint%% *}" "${hint%% *}" \ + local fleet_rank=60 + [ "$five_int" -ge 100 ] 2>/dev/null && fleet_rank=98 + notice_add "$fleet_rank" '+' acct "acct.fleet.${hint%% *}" "${hint%% *}" \ "$hint" "this account's 5h is spent · ${hint}" fi fi diff --git a/t/helpers.bash b/t/helpers.bash index bb6642e..8ec8db6 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -105,7 +105,7 @@ debug_log() { # Source individual functions by extracting them from statusline.sh. # This is deliberate: we test the actual production code, not copies. eval "$(awk ' - /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|get_cache_health_cli|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|rewrite_exposure_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } + /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|get_cache_health_cli|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|rewrite_exposure_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|transcript_low_priority_state|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index e9d6e44..ff8dea3 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -214,6 +214,14 @@ setup() { [[ "$plain" =~ 5h\[87%@[0-9]{2}:[0-9]{2}\] ]] } +@test "build_usage_display: a spent 5h window is cap, not 100-or-101 percent" { + reset_time=$(date -u -d '+2 hours' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":101,\"resets_at\":\"$reset_time\"},\"seven_day\":{\"utilization\":53}}" + plain=$(strip_ansi "$(build_usage_display "$usage" "")") + [[ "$plain" =~ 5h\[cap@[0-9]{2}:[0-9]{2}\] ]] + [[ "$plain" != *"101%"* ]] +} + @test "build_usage_display: 7d at 75% under pace pressure shows reset" { # 75% with ~2.2d left = ~4.8d elapsed: runway falls short of the deadline, # so the badge surfaces the absolute reset (verdict carried by color). @@ -4349,10 +4357,46 @@ JSON [ -z "$(build_advisor_line "$usage" auto)" ] } -@test "build_advisor_line: capped 5h adds no clause (line 1 already says it)" { +@test "build_advisor_line: capped 5h names the wall and return time" { reset_5h=$(date -u -d '+2 hours' '+%Y-%m-%dT%H:%M:%SZ') usage="{\"five_hour\":{\"utilization\":100,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":10}}" - [ -z "$(build_advisor_line "$usage" auto)" ] + plain=$(strip_ansi "$(build_advisor_line "$usage" auto)") + [[ "$plain" =~ ^!\ 5h\ capped\ ·\ back\ ~[0-9]{2}:[0-9]{2}$ ]] +} + +@test "transcript_low_priority_state: the gated offer and local toggle are current-window evidence" { + transcript=$(mktemp) + reset_epoch=$(( $(date +%s) + 7200 )) + reset_iso=$(date -u -d "@$reset_epoch" '+%Y-%m-%dT%H:%M:%SZ') + printf '%s\n' "{\"type\":\"assistant\",\"timestamp\":\"2026-09-02T07:29:28Z\",\"quotaLimits\":{\"rateLimitType\":\"five_hour\",\"resetsAt\":$reset_epoch,\"lowPriorityOffer\":\"treatment\"}}" > "$transcript" + [ "$(transcript_low_priority_state "$transcript" "$reset_iso")" = "offered" ] + + printf '%s\n' '{"type":"system","subtype":"local_command","timestamp":"2026-09-02T07:30:00Z","content":"Continuing now at lower priority until your limit resets. Run /low-priority to stop."}' >> "$transcript" + [ "$(transcript_low_priority_state "$transcript" "$reset_iso")" = "active" ] + + printf '%s\n' '{"type":"system","subtype":"local_command","timestamp":"2026-09-02T07:31:00Z","content":"Lower-priority mode is off. New messages wait for your usage limit as usual."}' >> "$transcript" + [ -z "$(transcript_low_priority_state "$transcript" "$reset_iso")" ] + # An offer from another window is not permission to advertise the mode. + [ -z "$(transcript_low_priority_state "$transcript" "$(date -u -d "@$((reset_epoch + 18000))" '+%Y-%m-%dT%H:%M:%SZ')")" ] + rm -f "$transcript" +} + +@test "notice_collect: a proven low-priority offer explains what bypasses the 5h wall" { + transcript=$(mktemp) + reset_epoch=$(( $(date +%s) + 7200 )) + reset_iso=$(date -u -d "@$reset_epoch" '+%Y-%m-%dT%H:%M:%SZ') + printf '%s\n' "{\"type\":\"assistant\",\"timestamp\":\"2026-09-02T07:29:28Z\",\"quotaLimits\":{\"rateLimitType\":\"five_hour\",\"resetsAt\":$reset_epoch,\"lowPriorityOffer\":\"treatment\"}}" > "$transcript" + usage="{\"five_hour\":{\"utilization\":101,\"resets_at\":\"$reset_iso\"},\"seven_day\":{\"utilization\":53}}" + plain=$(transcript_path="$transcript" build_advisor_line "$usage" auto) + [ "$(strip_ansi "$plain")" = "! 5h capped · /low-priority uses 7d" ] + no_week="{\"five_hour\":{\"utilization\":101,\"resets_at\":\"$reset_iso\"}}" + plain=$(transcript_path="$transcript" build_advisor_line "$no_week" auto) + [[ "$(strip_ansi "$plain")" == "! 5h capped · back ~"* ]] + + printf '%s\n' '{"type":"system","subtype":"local_command","timestamp":"2026-09-02T07:30:00Z","content":"Continuing now at lower priority until your limit resets. Run /low-priority to stop."}' >> "$transcript" + plain=$(transcript_path="$transcript" build_advisor_line "$usage" auto) + [ "$(strip_ansi "$plain")" = "+ lower priority · 47% of 7d left" ] + rm -f "$transcript" } @test "build_advisor_line: pace-hot 7d projects dry point with hard-stop tail" { From f0adc35190a161e61125b9b3107d1cad56b39b2a Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 2 Sep 2026 01:37:43 -0700 Subject: [PATCH 2/2] fix(cache): keep API-only quota fields live at cap --- CHANGELOG.md | 10 +++++++++- DESIGN.md | 2 ++ README.md | 2 ++ llms.txt | 4 ++-- statusline.sh | 30 ++++++++++++++++++++++++------ t/helpers.bash | 2 +- t/statusline.bats | 14 ++++++++++++++ 7 files changed, 54 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5acd27..2217b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased — a session wall is not a weekly wall +## v0.41.0 — 2026-09-02 — a session wall is not a weekly wall Claude Code 2.1.258 can offer `/low-priority` after the 5h session window binds, letting that session continue against the weekly pool while requests @@ -17,6 +17,14 @@ allowance, so no allowance percentage is guessed. A fresh sibling account still outranks the bypass because it avoids the queue and preserves this account's week. +The cache policy is explicit now too. A 5h cap is not a terminal account: +stdin continues to update 5h/7d on every render, while scoped and extra-usage +fields keep a finite two-minute API TTL. The outer spawn gate and the +lock-side cache recheck now share one function, with regression tests holding +100% to 120 seconds rather than to the reset. + +491 bats. + ## v0.40.0 — 2026-09-01 — what a cold cache costs **The bar's extra color above 200k was a pricing claim, and the price is diff --git a/DESIGN.md b/DESIGN.md index 43f68c7..452168a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -224,6 +224,8 @@ The API is asked for what the protocol does not hand us, and no more. a monotone envelope: a dip is stale, never a refund. - `/api/oauth/usage` (scoped weekly `fb`, extra usage): adaptive TTL by 5h heat (300→30 s) with a 120 s floor whenever stdin already carries 5h/7d. + A capped 5h window never freezes this cache: lower-priority requests still + move 7d and scoped counters, so API-only fields keep the finite 120 s TTL. - Profile: 24 h. Prepaid: 5 min. All caches shared per account under `~/.claude/statusline/[accounts//]` — one fetch serves every session, container, and ccpace (`docs/api/state-dir.md`). diff --git a/README.md b/README.md index 38061dc..e2ddc66 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,8 @@ shared dir -- `~/.claude/statusline/` -- and a single fetch serves all concurrent sessions. Per-session prompt-cache-health state stays under `~/.claude/statusline/sessions/`. Old `$SCRIPT_DIR` state is migrated on first run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior. +At a 5h cap, stdin keeps 5h/7d live and API-only scoped/extra fields continue +on a two-minute TTL; the cache is never frozen to the session reset. diff --git a/llms.txt b/llms.txt index fa81502..6d0eb50 100644 --- a/llms.txt +++ b/llms.txt @@ -8,7 +8,7 @@ > points went), a notice engine that pins the top condition and briefly > explains the next (cap projections, expiring-surplus and underuse advice, > fleet relief), and a `report` subcommand that ledgers what each closed -> window expired unused. 489 bats tests, deps are jq + curl, no daemon, no +> window expired unused. 491 bats tests, deps are jq + curl, no daemon, no > telemetry. Key facts for agents: @@ -45,7 +45,7 @@ Key facts for agents: - [statusline.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/statusline.sh): the entire product - [install.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/install.sh): installer (curl | bash, or STATUSLINE_SRC=) - [Makefile](https://github.com/thevibeworks/claude-code-statusline/blob/main/Makefile): install / status / diff / uninstall / check -- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 471 statusline tests +- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 473 statusline tests - [t/install.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/install.bats): 18 installer tests ## Optional diff --git a/statusline.sh b/statusline.sh index 5be3084..b9da165 100755 --- a/statusline.sh +++ b/statusline.sh @@ -896,6 +896,7 @@ fetch_error_badge() { # snapshot and session-boundary detection. fetch_usage_for_session() { local session_id="$1" + local stdin_rate_limits="${2:-0}" local cache_file="$CLAUDE_ACCOUNT_DIR/usage.cache" local lock_file="$CLAUDE_ACCOUNT_DIR/usage.lock" local err_file="$CLAUDE_ACCOUNT_DIR/usage.err" @@ -910,7 +911,8 @@ fetch_usage_for_session() { # Adaptive TTL: poll less when quota is low, more when it's hot local five_int=$(printf '%.0f' "$five_util" 2>/dev/null || echo 0) - local ttl=$(get_adaptive_ttl "$five_int") + local ttl + ttl=$(get_usage_fetch_ttl "$five_int" "$stdin_rate_limits") debug_log "fetch_usage_for_session: cache age=${age}s ttl=${ttl}s (5h_util=${five_int}%)" if [ $age -lt $ttl ]; then @@ -1865,6 +1867,21 @@ get_adaptive_ttl() { fi } +# The API-only half of the payload (model-scoped limits and extra usage) keeps +# moving while a 5h cap is bypassed by lower-priority service. Claude Code +# already supplies 5h/7d on stdin, so those sessions need the weekly-paced +# API floor, not the 30 s hot-window cadence — but they must never freeze to +# the reset. One helper owns the rule for both the outer spawn gate and the +# fetcher's lock-side recheck. +get_usage_fetch_ttl() { + local five_int="${1:-0}" stdin_rate_limits="${2:-0}" ttl + ttl=$(get_adaptive_ttl "$five_int") + if [ "$stdin_rate_limits" = "1" ] && [ "$ttl" -lt "$STDIN_RL_FETCH_TTL" ]; then + ttl="$STDIN_RL_FETCH_TTL" + fi + echo "$ttl" +} + # Atomically acquire a lock file, or return 1 if another process holds a # fresh one. noclobber (set -C) makes create-if-absent a single atomic step — # the old check-then-touch pattern left a window (hundreds of ms when a slow @@ -5637,14 +5654,15 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then age=$(($(date +%s) - fetched_at)) five_int=$(printf '%.0f' "$five_util" 2>/dev/null || echo 0) seven_int_cache=$(printf '%.0f' "$seven_util_cache" 2>/dev/null || echo 0) - ttl=$(get_adaptive_ttl "$five_int") + stdin_rate_limits=0 + if [ -n "$rl_five_pct" ] || [ -n "$rl_seven_pct" ]; then + stdin_rate_limits=1 + fi + ttl=$(get_usage_fetch_ttl "$five_int" "$stdin_rate_limits") # Claude Code hands 5h/7d on stdin every render (merged below), so # the API is only asked for what stdin lacks — the model-scoped # weekly limit and extra usage — which move at the week's pace, # not the sitting's: no need for the 30 s hot-window cadence. - if [ -n "$rl_five_pct" ] || [ -n "$rl_seven_pct" ]; then - [ "$ttl" -lt "$STDIN_RL_FETCH_TTL" ] && ttl=$STDIN_RL_FETCH_TTL - fi [ "$age" -ge "$ttl" ] && should_fetch=true fi @@ -5660,7 +5678,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then if [ "$should_fetch" = true ]; then reap_stale_lock "$lock_file" 15 - [ ! -f "$lock_file" ] && (fetch_usage_for_session "$session_id" >/dev/null 2>&1 &) + [ ! -f "$lock_file" ] && (fetch_usage_for_session "$session_id" "${stdin_rate_limits:-0}" >/dev/null 2>&1 &) fi if [ -f "$cache_file" ]; then diff --git a/t/helpers.bash b/t/helpers.bash index 8ec8db6..15e68df 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -105,7 +105,7 @@ debug_log() { # Source individual functions by extracting them from statusline.sh. # This is deliberate: we test the actual production code, not copies. eval "$(awk ' - /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|get_cache_health_cli|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|rewrite_exposure_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|transcript_low_priority_state|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } + /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|get_cache_health_cli|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|get_usage_fetch_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|rewrite_exposure_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|transcript_low_priority_state|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|hour_profile_mults|awake_secs|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index ff8dea3..82e39c7 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -2485,6 +2485,20 @@ EOF [ "$result" = "300" ] } +@test "get_usage_fetch_ttl: a capped session never freezes API-only fields" { + # Without stdin, hot-window cadence still applies. With Claude Code's + # live 5h/7d fields, the API is only serving scoped/extra data, so it gets + # the two-minute weekly floor — finite, never cached to the reset. + [ "$(get_usage_fetch_ttl 100 0)" = "30" ] + [ "$(get_usage_fetch_ttl 100 1)" = "$STDIN_RL_FETCH_TTL" ] +} + +@test "get_usage_fetch_ttl: stdin raises only faster adaptive cadences" { + [ "$(get_usage_fetch_ttl 10 1)" = "300" ] + [ "$(get_usage_fetch_ttl 30 1)" = "120" ] + [ "$(get_usage_fetch_ttl 60 1)" = "120" ] +} + # --- render_bar --- @test "render_bar: 50% fills half" {