diff --git a/.gitignore b/.gitignore index 50cb1d5..d767bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ sessions/ # design mockups (not shipped) assets/palette-retheme-mockup.svg +# devlogs — working notes, not project content +docs/devlog/ + # local captures and secrets — never version .cctrace/ .env diff --git a/CHANGELOG.md b/CHANGELOG.md index a69db71..bb7107a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## v0.18.0 — 2026-07-15 — freeze-safe cache expiry deadline, 7d hybrid reset + +**Fix: the 7d pressure suffix no longer decays in a frozen frame.** The +badge kept a relative countdown (`@6h`, `@<1h`) inside the last day — +the same decaying-countdown mistake the 5h badge fixed twice (v0.6, +v0.11), and it showed only under pressure, i.e. exactly when a stale +`@<1h` convinces you you're still capped after relief already arrived. +Now hybrid: day-relative `@Nd` while >= 24h out (decays one day per day; +mild, and the narrowest honest form), wall-clock `@04:00` inside the last +day — the 5h badge's idiom, unambiguous under 24h via next-occurrence +reading, and true no matter how stale the frame is. Zero width cost. +(Resolves the open decision in +docs/devlog/2026-07-15-statusline-refresh-mechanism.org.) + +**New: prompt-cache expiry warning that survives an idle gap — "quiet until +it bites".** Claude Code re-renders the statusline only on activity (mount, +new assistant message, mode/model change — no timer; see +docs/devlog/2026-07-15-statusline-refresh-mechanism.org), so nothing can +appear *during* the idle gap where the cache actually dies. And while active +the cache is always freshly ~1 TTL from expiry, so there is no honest +"expiring soon" to render. So `auto` stays silent while healthy — no width +on a deadline that's always ~an hour out — and speaks only when a rewrite +actually happened: + +- **`≡!419k` on resume**: the first post-idle render sees activity after + a gap longer than the TTL and reports the rewrite, sized at the re-cached + prefix (observed live: 68min idle -> `read=0 write=87869`, the full + context re-cached at ~20x the read rate; on subscriptions it burns 5h/7d + quota too). **Bold red past 200k** — the premium-band miss. Fires even + when the 300ms render debounce skipped the `read=0` turn, because the + detector keys off the stale activity anchor, not the one-frame collapse. + Held 60s wall-clock so a busy turn's refresh doesn't erase it. +- **Anchor-slide fix**: the activity anchor re-stamps only when the usage + numbers change. Renders also fire on vim/permission/model changes carrying + the previous turn's usage; re-stamping there slid the anchor forward and a + frozen frame would claim a warm cache after it died. +- **`--cache always` keeps the freeze-safe deadline** `≡@15:20` (last + request + TTL) for anyone who wants to read "resume or start fresh?" from + the frozen frame before typing. Wall-clock, never a countdown. +- **TTL default matches how the CLI actually caches** (from CC source + + live traces): claude.ai subscriber REPL sessions get `ttl:"1h"` on every + breakpoint; API-key / custom-endpoint auth stays on the stock 5m cache. + `FORCE_PROMPT_CACHING_5M` / `ENABLE_PROMPT_CACHING_1H` honored. An + observed `ephemeral_1h/5m` breakdown overrides and renders its class as + provenance (`≡:5m@14:25`); the CLI does not forward the breakdown + today. Known gap: subscriber sessions bootstrapped on overage latch 5m + server-side — invisible here; `≡!Nk` still reports the miss. + ## v0.17.0 — 2026-07-13 — mitm proxy trust, diagnosable !net, unified +X flash **Fix: every API fetch died behind a trusted mitm proxy (persistent `!net`).** diff --git a/README.md b/README.md index be0878e..3ffd880 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ Then add to `~/.claude/settings.json`: ## What It Shows ```text -project (main*) +84/-14 1h30m $6.72 opus4.8[1m][███░░░26%] cache:1h@14:20~ [MAX|you] 5h[87%@14:30] 7d[75%@2d] ex[$16.29/$200 8% bal$4.66] - | | | | | | | | | | | - path branch edits time cost model+context cache user 5h quota 7d quota extra usage +project (main*) +84/-14 1h30m $6.72 opus4.8[1m][███░░░26%] [MAX|you] 5h[87%@14:30] 7d[75%@2d] ex[$16.29/$200 8% bal$4.66] + | | | | | | | | | | + path branch edits time cost model+context user 5h quota 7d quota extra usage ``` Every component earns its place: @@ -84,9 +84,9 @@ Every component earns its place: | Effort | Compact lowercase badge: `lo` / `md` / `xh` / `max` / `ultra` / `auto` (`high` is the default and stays hidden). Dim for routine levels; `max` / `ultra` use the pressure color; `fast` shows in fast mode. | | Context bar | Merged with model. Green / yellow / red by window pressure. On 1M models the bar also carries the premium input-pricing band: yellow past 200k tokens, red past 800k — the % alone looks calm (320k = 32%) while every request bills at the premium rate. A real 0% (e.g. right after `/compact` resets the window) renders a visibly empty bar `[░░░░░░0%]` — the snap to empty *is* the refresh signal; the bar hides only when no context data exists at all. Changes flash for ~60s in reverse video: `[█░░░░░30%]+9` while filling, `[░░░░░░3%]-27` right after a compaction. Cost flashes the same way, cents-precise: `$7.90+.37`. | | User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. | -| Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge stays relative — `@5d` decays at day granularity, slower than any idle gap.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows explicit time remaining in the window: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now". Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. | +| Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge is hybrid: day-relative `@5d` while the reset is >= 24h out — decays one day per day, mild and narrow — switching to the same wall-clock `@04:00` inside the last day, where an `@6h`/`@<1h` countdown decayed by the hour exactly when pressure keeps the suffix visible.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows when relief arrives: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now"; inside the last day it reads `7d[92%@04:00]` — resets at 04:00. Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. | | Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. | -| Cache health | Detects observed prompt-cache rebuilds and cache-read drops. `cache!` on break, `cache~` when building. If a future Claude Code stdin includes TTL breakdown, `--cache always` can show `cache:1h@14:20`; current stdin usually exposes aggregate cache tokens only. Hidden when healthy by default. | +| Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at ~20x the read rate (and the same burn on your 5h/7d quota on subscriptions). **Bold red past 200k** — the premium-band miss. `≡~` while a large prefix rebuilds. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | **Color follows three lanes** so a glance is unambiguous: **status** (green/yellow/red) = pressure *only* — quota, context, cache, the premium @@ -112,7 +112,7 @@ red, matching its Claude Code TUI color) = model family; everything else is | Change flash on every refresh (`+.37` cost, `+9`/`-27` context, `+N` quota) | -- | Yes | | Model-scoped weekly quota (`fb`/`op`/`sn`) | -- | Yes | | Works behind trusted mitm proxies (NODE_EXTRA_CA_CERTS) | -- | Yes | -| 265 bats tests + CI | -- | Yes | +| 266 bats tests + CI | -- | Yes | ## Configuration @@ -161,23 +161,45 @@ when it matters. ### Prompt Cache ```text ---cache auto opus4.8[1m][21%] (healthy, hidden) ---cache auto opus4.8[1m][21%] cache~ (cache is rebuilding) ---cache auto opus4.8[1m][21%] cache! (cache-read collapse observed) ---cache always opus4.8[1m][21%] cache:1h@14:20 (when TTL breakdown exists) +--cache auto fabl5[1m][42%] (healthy: silent — no width spent) +--cache auto fabl5[1m][5%] ≡~ (a large prefix is rebuilding) +--cache auto fabl5[1m][43%] ≡!419k (resume onto a dead cache: 419k rewrite; BOLD red >200k) +--cache always opus4.8[1m][21%] ≡:1h@14:20 (opt in to the freeze-safe deadline; :1h = observed TTL) --cache off opus4.8[1m][21%] (disabled; no state writes) ``` -Cache state is based on Claude Code's per-turn usage tokens: -`cache_read_input_tokens`, `cache_creation_input_tokens`, and `input_tokens`. -The statusline can prove that a cache was read, rebuilt, or likely broke after a -turn. It cannot know server-side `expire_at` before the next request, so it shows -absolute last-observed cache activity (`@14:20`) rather than a live countdown. -Current Claude Code statusline stdin usually exposes aggregate cache tokens only, -not `cache_creation.ephemeral_1h_input_tokens` / `ephemeral_5m_input_tokens`. -The script accepts those fields for forward compatibility, but TTL class -(`5m` / `1h`) is shown only when observed in actual usage breakdown. Local -environment variables are intent, not proof of what the server accepted. +**Why healthy is silent.** The server refreshes the cache TTL on every request, +so while you work the cache is always freshly ~1 TTL from expiry — there is no +honest "expiring soon" to show, and a deadline that's always ~an hour out is +pure width. The expiry only becomes *real* during an idle gap, and Claude Code +renders the statusline only on activity — so no warning can appear while you're +away. The honest moment is when you come back: the first post-idle render sees +activity resume after a gap longer than the TTL and reports the rewrite as +`≡!Nk`, sized at the re-cached prefix (`≡!419k`), **bold red past +200k** — the expensive premium-band miss. This fires even when the 300ms render +debounce skipped the `read=0` turn, because the detector keys off the stale +activity anchor, not the one-frame collapse; breaks are held ~60s so a busy +turn's refresh doesn't erase them. A cache miss re-caches the whole prefix at +~20x the cache-read rate, and on subscription plans the same multiplier lands +on your 5h/7d quota. + +The activity anchor is the last observed *usage change*, not the last render — +Claude Code also re-runs the statusline on vim/permission/model changes with +unchanged usage, and re-stamping there would fake a warm cache. + +**The freeze-safe deadline, on demand.** `--cache always` adds `≡@15:20` +(last request + TTL): a past time in a frozen frame reads "expired at 15:20", +so you can decide *before* typing whether to resume this session or start +fresh. Wall-clock, never a countdown — "expires in 43m" rendered an hour ago is +a lie, "@15:20" stays true however stale the frame is. Claude Code's statusline +stdin exposes aggregate cache tokens only, not the `ephemeral_1h/5m` breakdown, +so the TTL is assumed from how the CLI actually requests caching (verified in +traces): **1h for claude.ai subscriber sessions, 5m for API-key / +custom-endpoint auth** (the CLI's `FORCE_PROMPT_CACHING_5M` / +`ENABLE_PROMPT_CACHING_1H` overrides are honored). An observed breakdown wins +and renders its class as provenance (`≡:5m@14:25`). Known gap: a subscriber +session that started while on overage is latched to 5m server-side, invisible +here — the `≡!Nk` badge still reports the miss after the fact. ### Quota Polling diff --git a/statusline.sh b/statusline.sh index 1762222..d767669 100755 --- a/statusline.sh +++ b/statusline.sh @@ -30,6 +30,32 @@ SEVEN_DAY_WINDOW_SECS=604800 # weekly quota window (fixed 7d) for pace math EXTRA_AUTO_UTIL_PCT=50 # show extra when its own utilization >= 50% CACHE_BREAK_MIN_TOKENS=2000 # ignore cache drops below this (noise) CACHE_BREAK_DROP_PCT=5 # cache read must drop >5% to count as break +CACHE_HEAVY_TOKENS=200000 # break badge turns bold-red past this: the + # >200k premium-band prefix, an expensive rewrite +# Cache-badge marker. Triple bar (U+2261 IDENTICAL TO): three stacked +# horizontal lines read as layered cache tiers. Single-column text glyph, so +# the padding math is unaffected. Swap for ☰ (U+2630) or Ξ (U+039E) if a +# font tofus it. +CACHE_GLYPH="${CACHE_GLYPH:-≡}" + +# Unobserved-TTL default for the cache expiry deadline. Mirrors the CLI's own +# rule (should1hCacheTTL, CC source): claude.ai subscribers in the REPL get +# cache_control ttl:"1h" (confirmed on every breakpoint in live traces); +# API-key / custom-endpoint auth stays on the stock 5m cache. The CLI's own +# FORCE_PROMPT_CACHING_5M / ENABLE_PROMPT_CACHING_1H overrides win when set. +# An observed ephemeral_1h/5m usage breakdown always overrides this default. +# Known gap: a subscriber session that bootstrapped on overage latches 5m, +# which we cannot see — the cache!Nk break badge still reports the miss. +_env_truthy() { case "$1" in 1|true|yes|on) return 0 ;; *) return 1 ;; esac; } +if _env_truthy "${FORCE_PROMPT_CACHING_5M:-}"; then + CACHE_TTL_DEFAULT="5m" +elif _env_truthy "${ENABLE_PROMPT_CACHING_1H:-}"; then + CACHE_TTL_DEFAULT="1h" +elif [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_BASE_URL:-}" ]; then + CACHE_TTL_DEFAULT="5m" +else + CACHE_TTL_DEFAULT="1h" +fi # Owner-only by default: caches and the debug log hold account PII (email, # uuid, org, paths). umask 077 keeps every file/dir we create unreadable to @@ -360,6 +386,10 @@ term_width=$(tput cols 2>/dev/null || echo 80) YELLOW='\033[0;33m' GREEN='\033[0;32m' RED='\033[0;31m' +# Bold red — one notch louder than pressure red (0;31), still the STATUS lane +# (distinct from fable's identity bright-red 0;91). Used only for a heavy cache +# rewrite (>= CACHE_HEAVY_TOKENS): the expensive miss deserves extra weight. +BOLD_RED='\033[1;31m' DIM='\033[2m' DIM_GREEN='\033[2;32m' DIM_RED='\033[2;31m' @@ -1049,6 +1079,16 @@ should_show_extra() { esac } +# Cache health state machine. Tracks the per-session cached-prefix numbers in +# a small JSON state file and classifies each render: +# ok warm cache, no anomaly +# building first write of a fresh prefix (read = 0, creation > 0) +# break the cached prefix shrank hard (drop rule), OR new activity landed +# after an idle gap longer than the TTL (the rewrite happened even +# when the 300ms render debounce skipped the read=0 frame), OR a +# recent break still held for QUOTA_BUMP_NOTICE_SECS +# none no usage numbers at all this render +# Echoes "state|ttl_class|last_active_at|break_tokens". get_cache_health() { local cache_read="${1:-0}" cache_creation="${2:-0}" uncached="${3:-0}" local state_file="${4:-}" ttl_class="${5:-}" cache_creation_1h="${6:-0}" cache_creation_5m="${7:-0}" @@ -1072,8 +1112,10 @@ get_cache_health() { fi local prev_cache_read="" + local prev_cache_creation="" local prev_ttl_class="" local last_active_at="" + local break_at=0 break_tokens=0 local now_epoch="${STATUSLINE_TEST_NOW_EPOCH:-$(date +%s)}" if [ -n "$ttl_class" ] && [ "$ttl_class" != "5m" ] && [ "$ttl_class" != "1h" ]; then @@ -1090,8 +1132,11 @@ get_cache_health() { if jq -e 'type == "object"' "$state_file" >/dev/null 2>&1; then eval "$(jq -r ' @sh "prev_cache_read=\(.cache_read // "")", + @sh "prev_cache_creation=\(.cache_creation // "")", @sh "prev_ttl_class=\(.ttl_class // "")", - @sh "last_active_at=\(.last_active_at // "")" + @sh "last_active_at=\(.last_active_at // "")", + @sh "break_at=\(.break_at // 0)", + @sh "break_tokens=\(.break_tokens // 0)" ' "$state_file" 2>/dev/null)" else prev_cache_read=$(cat "$state_file" 2>/dev/null) @@ -1101,8 +1146,57 @@ get_cache_health() { [ -z "$ttl_class" ] && ttl_class="$prev_ttl_class" + # Anchor for the expiry deadline: the last REQUEST, not the last render. + # Renders also fire on vim/permission/model changes carrying the previous + # message's usage unchanged; re-stamping there slides the anchor forward + # and a frozen frame would claim a warm cache after it already died. + # Stamp only when the usage numbers actually moved (new API activity). + local usage_changed="" if [ "$cache_read" -gt 0 ] || [ "$cache_creation" -gt 0 ]; then - last_active_at="$now_epoch" + if [ "$cache_read" != "${prev_cache_read:-}" ] || [ "$cache_creation" != "${prev_cache_creation:-}" ]; then + usage_changed=1 + fi + fi + local prev_active="${last_active_at:-}" + [ -n "$usage_changed" ] && last_active_at="$now_epoch" + + local ttl_secs=3600 + [ "${ttl_class:-$CACHE_TTL_DEFAULT}" = "5m" ] && ttl_secs=300 + + local state="ok" + if [ -z "$prev_cache_read" ] || [ "$prev_cache_read" -le 0 ] 2>/dev/null; then + if [ "$cache_read" -le 0 ] && [ "$cache_creation" -gt 0 ]; then + state="building" + fi + else + local drop=$((prev_cache_read - cache_read)) + local threshold=$((prev_cache_read * (100 - CACHE_BREAK_DROP_PCT) / 100)) + if [ "$drop" -gt "$CACHE_BREAK_MIN_TOKENS" ] && [ "$cache_read" -lt "$threshold" ]; then + state="break" + break_at="$now_epoch" + break_tokens="$cache_creation" + elif [ -n "$usage_changed" ] && [ -n "$prev_active" ] \ + && [ $((now_epoch - prev_active)) -gt "$ttl_secs" ] 2>/dev/null; then + # Idle expiry: activity after a gap longer than the TTL means the + # whole prefix was re-written, whether or not any render caught + # the read=0 frame. Size estimate: the prefix that got re-cached. + state="break" + break_at="$now_epoch" + break_tokens=$((cache_read + cache_creation)) + elif [ "$cache_read" -le 0 ] && [ "$cache_creation" -gt "$CACHE_BREAK_MIN_TOKENS" ]; then + state="building" + fi + fi + + # A break is HELD for QUOTA_BUMP_NOTICE_SECS: the single render that + # catches the rewrite is often replaced within seconds during a busy turn. + if [ "$state" != "break" ]; then + if [ "${break_at:-0}" -gt 0 ] 2>/dev/null && [ $((now_epoch - break_at)) -lt "$QUOTA_BUMP_NOTICE_SECS" ] 2>/dev/null; then + state="break" + else + break_at=0 + break_tokens=0 + fi fi if [ -n "$state_file" ]; then @@ -1114,6 +1208,8 @@ get_cache_health() { --argjson uncached "$uncached" \ --arg ttl "$ttl_class" \ --argjson last_active "${last_active_at:-0}" \ + --argjson break_at "${break_at:-0}" \ + --argjson break_tokens "${break_tokens:-0}" \ --argjson updated_at "$now_epoch" \ '{ cache_read: $cache_read, @@ -1121,34 +1217,14 @@ get_cache_health() { uncached: $uncached, ttl_class: (if $ttl == "" then null else $ttl end), last_active_at: (if $last_active > 0 then $last_active else null end), + break_at: (if $break_at > 0 then $break_at else null end), + break_tokens: (if $break_tokens > 0 then $break_tokens else null end), updated_at: $updated_at }' > "$tmp_state" 2>/dev/null && mv -f "$tmp_state" "$state_file" 2>/dev/null rm -f "$tmp_state" 2>/dev/null fi - if [ -z "$prev_cache_read" ] || [ "$prev_cache_read" -le 0 ] 2>/dev/null; then - if [ "$cache_read" -le 0 ] && [ "$cache_creation" -gt 0 ]; then - echo "building|${ttl_class}|${last_active_at}" - return - fi - echo "ok|${ttl_class}|${last_active_at}" - return - fi - - local drop=$((prev_cache_read - cache_read)) - local threshold=$((prev_cache_read * (100 - CACHE_BREAK_DROP_PCT) / 100)) - - if [ "$drop" -gt "$CACHE_BREAK_MIN_TOKENS" ] && [ "$cache_read" -lt "$threshold" ]; then - echo "break|${ttl_class}|${last_active_at}" - return - fi - - if [ "$cache_read" -le 0 ] && [ "$cache_creation" -gt "$CACHE_BREAK_MIN_TOKENS" ]; then - echo "building|${ttl_class}|${last_active_at}" - return - fi - - echo "ok|${ttl_class}|${last_active_at}" + echo "${state}|${ttl_class}|${last_active_at}|${break_tokens}" } infer_cache_ttl_class() { @@ -1196,37 +1272,67 @@ _fmt_epoch() { return 0 } -format_cache_active_time() { - local ts="${1:-}" - [ -z "$ts" ] || [ "$ts" = "null" ] || [ "$ts" = "0" ] && return - _fmt_epoch "$ts" '%H:%M' -} - +# Cache badge — "quiet until it bites". Claude Code never re-renders an idle +# session, and while active the cache is always freshly ~1 TTL from expiry, so +# a proactive "expiring soon" is not honestly observable for a 1h (subscriber) +# cache: the expiry only becomes real during an idle gap that produces no +# render to warn in. So auto mode stays SILENT while healthy — no width spent +# on a deadline that's always ~an hour out mid-session — and speaks only when +# the rewrite actually happened: +# cache! a resume landing on a dead cache (idle-expiry break) or a +# mid-session prefix collapse. = re-cached tokens; the +# miss bills at ~20x the read rate (and burns 5h/7d quota on +# subscriptions). BOLD red past CACHE_HEAVY_TOKENS — the >200k +# premium-band rewrite the user asked to highlight. +# cache~ a large prefix is (re)building this turn. +# --cache always keeps the freeze-safe absolute deadline (cache@HH:MM = last +# request + TTL): a past time in a frozen frame reads "expired at HH:MM", the +# same wall-clock idiom as the 5h @reset. TTL: the CLI does not forward the +# ephemeral_1h/5m breakdown today (ttl_class stays null), so an unobserved TTL +# assumes CACHE_TTL_DEFAULT; an observed class renders as provenance +# (cache:5m@…) and overrides it. build_cache_indicator() { local health_result="$1" mode="${2:-auto}" - local state ttl_class last_active_at active_time - IFS='|' read -r state ttl_class last_active_at <<<"$health_result" + local state ttl_class last_active_at break_tokens + IFS='|' read -r state ttl_class last_active_at break_tokens <<<"$health_result" case "$mode" in "off") return ;; esac - active_time=$(format_cache_active_time "$last_active_at") + # Absolute expiry deadline (last request + TTL) — rendered only in `always`. + local ttl_secs=3600 + [ "${ttl_class:-$CACHE_TTL_DEFAULT}" = "5m" ] && ttl_secs=300 local meta="" - if [ -n "$ttl_class" ]; then - meta=":${ttl_class}" - [ -n "$active_time" ] && meta="${meta}@${active_time}" + if [ -n "$last_active_at" ] && [ "$last_active_at" -gt 0 ] 2>/dev/null; then + local deadline + deadline=$(_fmt_epoch $((last_active_at + ttl_secs)) '%H:%M') + [ -n "$deadline" ] && meta="@${deadline}" fi + [ -n "$ttl_class" ] && [ -n "$meta" ] && meta=":${ttl_class}${meta}" case "$state" in "break") - echo "${RED}cache!${RESET}" + # Rewrite size quantifies the hit: ≡!195k = a 195k-token prefix + # was re-cached at the write rate. Bold past the heavy threshold + # — a >200k premium-band miss is the expensive one. + local size="" + [ "${break_tokens:-0}" -ge 1000 ] 2>/dev/null && size="$((break_tokens / 1000))k" + local col="$RED" + [ "${break_tokens:-0}" -ge "$CACHE_HEAVY_TOKENS" ] 2>/dev/null && col="$BOLD_RED" + echo "${col}${CACHE_GLYPH}!${size}${RESET}" ;; "building") - echo "${DIM_YELLOW}cache${meta}~${RESET}" + if [ "$mode" = "always" ]; then + echo "${DIM_YELLOW}${CACHE_GLYPH}${meta}~${RESET}" + else + echo "${DIM_YELLOW}${CACHE_GLYPH}~${RESET}" + fi ;; "ok") - [ "$mode" = "always" ] && [ -n "$meta" ] && echo "${DIM}cache${meta}${RESET}" + # Healthy: silent in auto (the deadline is always ~1 TTL out while + # active — noise), shown only when explicitly requested. + [ "$mode" = "always" ] && [ -n "$meta" ] && echo "${DIM}${CACHE_GLYPH}${meta}${RESET}" ;; esac return 0 @@ -1937,7 +2043,8 @@ build_usage_display() { # deadline. WALL-CLOCK (@14:30), not a countdown: the statusline only # re-renders on activity, so "@1h38m" decays into a lie during idle gaps # while "@14:30" stays true in a frozen frame (see format_reset_absolute). - # The 7d badge keeps relative @Nd — day granularity outlives any idle gap. + # The 7d badge is hybrid: day-relative @Nd while >= 24h out, wall-clock + # @HH:MM inside the last day (see the 7d reset_suffix below). # Recovery color (DIM_GREEN) when high usage + reset <= 30min. if [ "$five_int" -gt 0 ] 2>/dev/null; then local color=$(get_usage_color "$five_int") @@ -2007,22 +2114,26 @@ build_usage_display() { color="$DIM_GREEN" fi - # Under pressure, be explicit about how long the window still has to - # run: @Nd / @Nh REMAINING (timezone-proof, unlike a day name). Shown - # whenever the verdict warns — abnormal burn 5 days out included — - # or usage is already high. On-pace badges stay a bare 7d[NN%]. + # Under pressure, be explicit about when relief arrives. Hybrid form: + # >= 24h out stays day-relative (@Nd — decays one day per day in a + # frozen frame; mild, and the narrowest honest form), but inside the + # last day it switches to the 5h badge's wall-clock idiom (@04:00). + # The old @Nh/@<1h countdown decayed by the hour during idle — worst + # exactly when pressure keeps this suffix visible, convincing you + # you're still capped after relief already arrived. Under 24h a bare + # @HH:MM is unambiguous (next occurrence), same as the 5h badge. + # Shown whenever the verdict warns — abnormal burn 5 days out + # included — or usage is already high. On-pace badges stay 7d[NN%]. local reset_suffix="" if [ -n "$reset_secs" ] && [ "$reset_secs" -gt 0 ] 2>/dev/null \ && { [ "$sev_level" != "green" ] || [ "$seven_int" -ge 85 ] 2>/dev/null; }; then local rem="" if [ "$reset_secs" -ge 86400 ]; then rem="$(( reset_secs / 86400 ))d" - elif [ "$reset_secs" -ge 3600 ]; then - rem="$(( reset_secs / 3600 ))h" else - rem="<1h" + rem=$(format_reset_absolute "$seven_reset") fi - reset_suffix="${DIM}@${rem}${color}" + [ -n "$rem" ] && reset_suffix="${DIM}@${rem}${color}" fi local bump_part="" [ "$seven_bump" -gt 0 ] 2>/dev/null && bump_part="${color}${REVERSE}+${seven_bump}${NO_REVERSE}" diff --git a/t/helpers.bash b/t/helpers.bash index a0aa55b..440c4e9 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -19,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" YELLOW='\033[0;33m' GREEN='\033[0;32m' RED='\033[0;31m' +BOLD_RED='\033[1;31m' DIM='\033[2m' DIM_GREEN='\033[2;32m' DIM_RED='\033[2;31m' @@ -37,6 +38,9 @@ SEVEN_DAY_WINDOW_SECS=604800 EXTRA_AUTO_UTIL_PCT=50 CACHE_BREAK_MIN_TOKENS=2000 CACHE_BREAK_DROP_PCT=5 +CACHE_TTL_DEFAULT="1h" +CACHE_HEAVY_TOKENS=200000 +CACHE_GLYPH="≡" DEBUG_LOG_MAX_BYTES=1048576 USAGE_LOG_MAX_BYTES=33554432 @@ -47,7 +51,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|infer_cache_ttl_class|format_cache_active_time|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|premium_band_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|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)\(\)/ { 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|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|premium_band_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|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)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index 77edcf6..ca35654 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -708,6 +708,16 @@ EOF [[ "$plain" == *"7d[75%@5d]"* ]] } +@test "build_usage_display: pressure inside the last day shows wall-clock reset" { + # < 24h to reset: the old @Nh/@<1h countdown decayed by the hour in a + # frozen frame — the badge now uses the 5h idiom, absolute @HH:MM. + reset_time=$(date -u -d '+6 hours' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":10},\"seven_day\":{\"utilization\":90,\"resets_at\":\"$reset_time\"}}" + plain=$(strip_ansi "$(build_usage_display "$usage" "")") + expected=$(_fmt_epoch "$(_epoch_from_ts "$reset_time")" '%H:%M') + [[ "$plain" == *"7d[90%@${expected}]"* ]] +} + # --- get_adaptive_ttl --- @test "get_adaptive_ttl: low usage = 5 min" { @@ -1782,13 +1792,66 @@ JSON STATUSLINE_TEST_NOW_EPOCH=1778728000 export STATUSLINE_TEST_NOW_EPOCH result=$(get_cache_health 200000 0 1 "$tmpdir/cache_health" "1h") - [[ "$result" == "ok|1h|1778728000" ]] + [[ "$result" == "ok|1h|1778728000|0" ]] [ "$(jq -r '.ttl_class' "$tmpdir/cache_health")" = "1h" ] [ "$(jq -r '.last_active_at' "$tmpdir/cache_health")" = "1778728000" ] unset STATUSLINE_TEST_NOW_EPOCH rm -rf "$tmpdir" } +@test "get_cache_health: unchanged usage does not slide the expiry anchor" { + # Renders also fire on vim/permission/model changes carrying the SAME + # usage; re-stamping there would let a frozen frame claim a warm cache + # after it already died (see the anchor comment in get_cache_health). + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health 200000 500 1 "$tmpdir/ch" >/dev/null + result=$(STATUSLINE_TEST_NOW_EPOCH=4000 get_cache_health 200000 500 1 "$tmpdir/ch") + [ "$result" = "ok||1000|0" ] + [ "$(jq -r '.last_active_at' "$tmpdir/ch")" = "1000" ] + rm -rf "$tmpdir" +} + +@test "get_cache_health: changed usage re-stamps the expiry anchor" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health 200000 500 1 "$tmpdir/ch" >/dev/null + STATUSLINE_TEST_NOW_EPOCH=1500 get_cache_health 200500 800 1 "$tmpdir/ch" >/dev/null + [ "$(jq -r '.last_active_at' "$tmpdir/ch")" = "1500" ] + rm -rf "$tmpdir" +} + +@test "get_cache_health: activity after a gap past the TTL is a break (idle expiry)" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health 200000 500 1 "$tmpdir/ch" >/dev/null + # 2h later the 1h cache is long dead. The read=0 rewrite frame was + # skipped (300ms debounce) — the changed numbers + stale anchor alone + # must flag the rewrite, sized at the re-cached prefix. + result=$(STATUSLINE_TEST_NOW_EPOCH=8200 get_cache_health 201000 800 1 "$tmpdir/ch") + [ "$result" = "break||8200|201800" ] + rm -rf "$tmpdir" +} + +@test "get_cache_health: activity within the TTL window is not a break" { + tmpdir=$(mktemp -d) + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health 200000 500 1 "$tmpdir/ch" >/dev/null + result=$(STATUSLINE_TEST_NOW_EPOCH=3000 get_cache_health 201000 800 1 "$tmpdir/ch") + [[ "$result" == ok\|* ]] + rm -rf "$tmpdir" +} + +@test "get_cache_health: break is held for the notice window, then clears" { + tmpdir=$(mktemp -d) + echo "200000" > "$tmpdir/ch" + STATUSLINE_TEST_NOW_EPOCH=1000 get_cache_health 0 200100 1 "$tmpdir/ch" >/dev/null + # 30s later the cache is warm again, but the break notice must survive + # the render that replaced the one-frame rewrite signal. + result=$(STATUSLINE_TEST_NOW_EPOCH=1030 get_cache_health 200100 900 1 "$tmpdir/ch") + [[ "$result" == break\|* ]] + [[ "$result" == *"|200100" ]] + result=$(STATUSLINE_TEST_NOW_EPOCH=1090 get_cache_health 200100 900 1 "$tmpdir/ch") + [[ "$result" == ok\|* ]] + rm -rf "$tmpdir" +} + @test "get_cache_health: creation breakdown overrides ttl class" { tmpdir=$(mktemp -d) result=$(get_cache_health 0 50000 1 "$tmpdir/cache_health" "" 50000 0) @@ -1804,7 +1867,7 @@ JSON STATUSLINE_TEST_NOW_EPOCH=1740 export STATUSLINE_TEST_NOW_EPOCH result=$(get_cache_health 0 0 1 "$tmpdir/cache_health") - [[ "$result" == "break|5m|1000" ]] + [[ "$result" == "break|5m|1000|0" ]] unset STATUSLINE_TEST_NOW_EPOCH rm -rf "$tmpdir" } @@ -1827,21 +1890,65 @@ JSON rm -rf "$tmpdir" } -@test "build_cache_indicator: auto hides healthy ttl metadata" { - result=$(build_cache_indicator "ok|1h|1778728000" "auto") - [ -z "$result" ] +@test "build_cache_indicator: auto stays silent while healthy (any size)" { + # "quiet until it bites": the deadline is always ~1 TTL out mid-session, + # so auto spends no width on it. Silent regardless of prefix size. + [ -z "$(build_cache_indicator "ok|1h|1778728000|0" "auto")" ] + [ -z "$(build_cache_indicator "ok||1778728000|0" "auto")" ] } -@test "build_cache_indicator: always shows ttl and observed time" { - result=$(build_cache_indicator "ok|1h|1778728000" "always") +@test "build_cache_indicator: always shows expiry deadline (anchor + observed ttl)" { + result=$(TZ=UTC build_cache_indicator "ok|1h|1778728000|0" "always") plain=$(strip_ansi "$result") - [[ "$plain" =~ ^cache:1h@[0-9]{2}:[0-9]{2}$ ]] + expected=$(TZ=UTC _fmt_epoch $((1778728000 + 3600)) '%H:%M') + [ "$plain" = "${CACHE_GLYPH}:1h@${expected}" ] } -@test "build_cache_indicator: building shows ttl metadata" { - result=$(build_cache_indicator "building|5m|1778728000" "auto") +@test "build_cache_indicator: unobserved ttl assumes the default, no class shown" { + # bare @HH:MM = assumed TTL; the :1h/:5m provenance appears only when + # the usage breakdown actually reported the class. + result=$(TZ=UTC build_cache_indicator "ok||1778728000|0" "always") plain=$(strip_ansi "$result") - [[ "$plain" =~ ^cache:5m@[0-9]{2}:[0-9]{2}~$ ]] + expected=$(TZ=UTC _fmt_epoch $((1778728000 + 3600)) '%H:%M') + [ "$plain" = "${CACHE_GLYPH}@${expected}" ] +} + +@test "build_cache_indicator: building is a bare cache~ in auto" { + result=$(build_cache_indicator "building|5m|1778728000|0" "auto") + plain=$(strip_ansi "$result") + [ "$plain" = "${CACHE_GLYPH}~" ] +} + +@test "build_cache_indicator: building carries the deadline under --cache always" { + result=$(TZ=UTC build_cache_indicator "building|5m|1778728000|0" "always") + plain=$(strip_ansi "$result") + expected=$(TZ=UTC _fmt_epoch $((1778728000 + 300)) '%H:%M') + [ "$plain" = "${CACHE_GLYPH}:5m@${expected}~" ] +} + +@test "build_cache_indicator: break shows the rewrite size" { + result=$(build_cache_indicator "break|1h|1778728000|195000" "auto") + plain=$(strip_ansi "$result") + [ "$plain" = "${CACHE_GLYPH}!195k" ] +} + +@test "build_cache_indicator: sub-1k break renders bare cache!" { + result=$(build_cache_indicator "break|1h|1778728000|900" "auto") + plain=$(strip_ansi "$result") + [ "$plain" = "${CACHE_GLYPH}!" ] +} + +@test "build_cache_indicator: a heavy (>200k) rewrite renders bold red" { + # the >200k premium-band miss the user asked to highlight + result=$(build_cache_indicator "break|1h|1778728000|419000" "auto") + [ "$(strip_ansi "$result")" = "${CACHE_GLYPH}!419k" ] + [[ "$result" == *'\033[1;31m'* ]] +} + +@test "build_cache_indicator: a sub-200k rewrite stays plain red, not bold" { + result=$(build_cache_indicator "break|1h|1778728000|150000" "auto") + [[ "$result" != *'\033[1;31m'* ]] + [[ "$result" == *'\033[0;31m'* ]] } # --- integration: cache health indicator --- @@ -1853,19 +1960,34 @@ JSON result=$(echo '{"model":{"id":"claude-opus-4-6[1m]","display_name":"Opus"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.150","context_window":{"used_percentage":21,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":58,"cache_creation_input_tokens":200100,"cache_read_input_tokens":0}}}' \ | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) plain=$(strip_ansi "$result") - [[ "$plain" == *"cache!"* ]] + [[ "$plain" == *"${CACHE_GLYPH}!"* ]] rm -rf "$tmpdir" } -@test "integration: healthy cache shows no indicator" { +@test "integration: healthy long-context cache stays silent in auto mode" { + # "quiet until it bites": a warm 209k cache spends zero width. The + # freeze-safe deadline is available on demand via --cache always + # (asserted separately); auto shows nothing until the rewrite happens. tmpdir=$(mktemp -d) mkdir -p "$tmpdir/sessions" - echo "200000" > "$tmpdir/sessions/test-session-id_cache_health" + # Fresh anchor: a stale one would (correctly) fire idle-expiry. This + # asserts the warm, actively-worked case shows nothing. + echo "{\"cache_read\":200000,\"last_active_at\":$(date +%s)}" > "$tmpdir/sessions/test-session-id_cache_health" result=$(echo '{"model":{"id":"claude-opus-4-6[1m]","display_name":"Opus"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.150","context_window":{"used_percentage":21,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":58,"cache_creation_input_tokens":173,"cache_read_input_tokens":209703}}}' \ | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) plain=$(strip_ansi "$result") - [[ "$plain" != *"cache!"* ]] - [[ "$plain" != *"cache~"* ]] + [[ "$plain" != *"${CACHE_GLYPH}"* ]] + rm -rf "$tmpdir" +} + +@test "integration: --cache always surfaces the deadline on a warm cache" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/sessions" + echo "{\"cache_read\":200000,\"ttl_class\":\"1h\",\"last_active_at\":$(date +%s)}" > "$tmpdir/sessions/test-session-id_cache_health" + result=$(echo '{"model":{"id":"claude-opus-4-6[1m]","display_name":"Opus"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.150","context_window":{"used_percentage":21,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":58,"cache_creation_input_tokens":173,"cache_read_input_tokens":209703}}}' \ + | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test --cache always) + plain=$(strip_ansi "$result") + [[ "$plain" =~ ${CACHE_GLYPH}:1h@[0-9]{2}:[0-9]{2} ]] rm -rf "$tmpdir" } @@ -1875,17 +1997,21 @@ JSON result=$(echo '{"model":{"id":"claude-opus-4-6[1m]","display_name":"Opus"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.150","context_window":{"used_percentage":5,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":10,"cache_creation_input_tokens":50000,"cache_read_input_tokens":0}}}' \ | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) plain=$(strip_ansi "$result") - [[ "$plain" == *"cache~"* ]] + # auto building is a bare marker (the deadline is --cache always only) + [[ "$plain" == *"${CACHE_GLYPH}~"* ]] + [[ "$plain" != *"@"* ]] rm -rf "$tmpdir" } @test "integration: first turn building with 1h breakdown shows ttl class" { + # The ttl-class provenance rides on the deadline, which only --cache + # always renders; auto building is a bare cache~. tmpdir=$(mktemp -d) mkdir -p "$tmpdir/sessions" result=$(echo '{"model":{"id":"claude-opus-4-6[1m]","display_name":"Opus"},"cwd":"/tmp/test","workspace":{"current_dir":"/tmp/test"},"cost":{"total_cost_usd":0,"total_lines_added":0,"total_lines_removed":0,"total_api_duration_ms":0},"version":"2.1.150","context_window":{"used_percentage":5,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":10,"cache_creation_input_tokens":50000,"cache_creation":{"ephemeral_1h_input_tokens":50000,"ephemeral_5m_input_tokens":0},"cache_read_input_tokens":0}}}' \ - | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test) + | CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" --test --cache always) plain=$(strip_ansi "$result") - [[ "$plain" == *"cache:1h@"* ]] + [[ "$plain" == *"${CACHE_GLYPH}:1h@"* ]] [[ "$plain" == *"~"* ]] rm -rf "$tmpdir" } @@ -1893,13 +2019,15 @@ JSON @test "integration: --cache always shows healthy ttl metadata" { tmpdir=$(mktemp -d) mkdir -p "$tmpdir/sessions" - cat > "$tmpdir/sessions/test-session-id_cache_health" <<'JSON' -{"cache_read":200000,"ttl_class":"1h","last_active_at":1778728000} + # Anchor must be fresh: a stale anchor + new usage is (correctly) an + # idle-expiry break now, which is not what this test is about. + cat > "$tmpdir/sessions/test-session-id_cache_health" <