My Claude Code status line races a tortoise against a hare to show whether I'm burning quota faster than a steady pace. Last time I switched the main bar to the 7-day window.
I'd kept Fable away from long-running agents β it goes through tokens fast. Then Fable 5.1 landed. The announcement said it's "more comfortable with long, unattended work than Fable 5," and estimated it would cost about 25% less than Fable 5 for typical workloads β up to about 45% less for highly agentic work. I wanted to see how it would hold up, so I left agents running on it. The status line said the week was fine, and I found out afterwards that I'd gone through Fable's own weekly limit and into extra usage β about $200 of it. I hadn't known that Fable's weekly limit could also be extended with extra usage.
The status line wasn't wrong. It was watching the wrong bucket.
Besides the all-models 7-day window, there are per-model weekly limits. /usage shows them, but the status line payload doesn't carry them: rate_limits.seven_day is the all-models bucket, even while you're on Fable. So 7d:47% can look perfectly relaxed while Fable is at 100%.
Now the line looks like this:
[Opus 5] Β·Β·Β·Β·Β·Β·Β·Β·Β·ππ’Β·Β·Β·Β·Β·Β·Β·Β·Β· 7d(all):47%@9/16 | Β·πΒ·π’Β·Β·Β·Β·Β·Β· 5h:15%@14:50 | Fable 7d:87% β οΈ
7d(all) β renamed from 7d, so it's obvious it's not model-specific. Fable 7d:87% β οΈ β the per-model weekly bucket.
Which per-model buckets get shown:
Fable 7d:β¦.
β οΈ on a per-model segment uses the same tortoise rule as the bars: usage percentage > elapsed fraction of the window. Unlike the bars, it's suppressed below 25%, so a fresh window doesn't flag on the first few messages.
π¦ The full code is in this gist β both scripts, the settings snippet, and a README. The sections below walk through it.
The per-model numbers come from https://api.anthropic.com/api/oauth/usage β the endpoint /usage reads β authenticated with the Claude Code OAuth token. It's undocumented, so use this at your own risk. More on that at the end.
This version is two scripts instead of one. Both live in ~/.claude/:
~/.claude/
βββ settings.json # statusLine points at statusline.sh only
βββ statusline.sh # updated β draws the line, starts usage-fetch.sh
βββ usage-fetch.sh # new β calls the usage endpoint, writes the cache
βββ usage-cache.json # written by usage-fetch.sh, read by statusline.sh
settings.json only knows about statusline.sh. You never run usage-fetch.sh yourself β statusline.sh starts it in the background at most once every 5 minutes, and reads whatever it last cached:
on every render : Claude Code β statusline.sh β reads usage-cache.json β prints the line
every 5 minutes : statusline.sh β starts usage-fetch.sh in the background
in background : usage-fetch.sh β calls the usage endpoint β writes usage-cache.json
The relevant part of the response:
{
"limits": [
{
"kind": "weekly_scoped",
"scope": { "model": { "display_name": "Fable" } },
"percent": 0.87,
"resets_at": "2026-09-16T09:00:00.126747+00:00"
}
]
}
The script keeps only the per-model weekly buckets and caches them:
{"fetched_at":1789266899,"buckets":[{"name":"Fable","pct":87,"resets_at":1789549199}]}
bash
#!/bin/bash
set -u
CACHE=~/.claude/usage-cache.json
LOCK=~/.claude/.usage-fetch.lock
ATTEMPT=~/.claude/.usage-fetch-attempt
if [ -d "$LOCK" ]; then
[ -n "$(find "$LOCK" -maxdepth 0 -mmin +2 2>/dev/null)" ] && rmdir "$LOCK" 2>/dev/null
fi
mkdir "$LOCK" 2>/dev/null || exit 0
trap 'rmdir "$LOCK" 2>/dev/null' EXIT
date +%s > "$ATTEMPT"
TOKEN=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null \
| jq -r '.claudeAiOauth.accessToken // .accessToken // .access_token // empty' 2>/dev/null)
[ -n "${TOKEN:-}" ] || TOKEN=$(jq -r '.claudeAiOauth.accessToken // .accessToken // .access_token // empty' \
~/.claude/.credentials.json 2>/dev/null)
[ -n "${TOKEN:-}" ] || exit 0
RESP=$(curl -sS --max-time 8 https://api.anthropic.com/api/oauth/usage \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "anthropic-beta: oauth-2025-04-20" 2>/dev/null)
unset TOKEN
[ -n "$RESP" ] || exit 0
OUT=$(printf '%s' "$RESP" | jq -c --argjson now "$(date +%s)" '
def iso2epoch:
if type != "string" then null else
capture("^(?<b>\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(\\.\\d+)?(?<tz>Z|[+-]\\d{2}:\\d{2})?$") as $c
| if $c == null then null else
(($c.b + "Z") | fromdateiso8601) as $t
| ($c.tz // "Z") as $tz
| if $tz == "Z" then $t
else (($tz[1:3] | tonumber) * 3600 + ($tz[4:6] | tonumber) * 60) as $off
| if $tz[0:1] == "+" then $t - $off else $t + $off end
end
end
end;
{fetched_at: $now,
buckets: [ (.limits // [])[]
| select(.kind == "weekly_scoped" and (.scope.model.display_name | type) == "string")
| {name: .scope.model.display_name,
pct: (if (.percent // 0) <= 1 then (.percent // 0) * 100 else .percent end),
resets_at: (.resets_at | iso2epoch)} ]}
| select(.buckets | length > 0)' 2>/dev/null)
[ -n "$OUT" ] || exit 0
umask 077
printf '%s\n' "$OUT" > "$CACHE".tmp && mv -f "$CACHE".tmp "$CACHE"
A few details:
~/.claude/.credentials.json on other platforms), passed to curl, and unset. Only percentages and reset times are written to disk, with umask 077. mkdir as a lock.resets_at needs a hand-rolled parser.fromdateiso8601 rejects both the fractional seconds and the +00:00 offset.tmp + mv`` statusline.sh never reads a half-written file.
Everything up to the 5h bar is the same as the previous post. New is the block at the end and the 7d(all) label:
#!/bin/bash
input=$(cat)
MODEL=$(echo "$input" | jq -r '.model.display_name')
FIVE_H_PCT=$(echo "$input" | jq -r '(.rate_limits.five_hour.used_percentage // 0)')
SEVEN_D_PCT=$(echo "$input" | jq -r '(.rate_limits.seven_day.used_percentage // 0)')
FIVE_H_RESETS=$(echo "$input" | jq -r '.rate_limits.five_hour.resets_at // empty')
SEVEN_D_RESETS=$(echo "$input" | jq -r '.rate_limits.seven_day.resets_at // empty')
NOW=$(date +%s)
TZ=Asia/Tokyo # change to your local timezone
W7=20; W5=10 # bar widths: 7d full, 5h half
make_bar() {
local actual=$1 ideal=$2 width=$3 bar="" i
for i in $(seq 0 $((width - 1))); do
if [ "$i" -eq "$ideal" ] && [ "$i" -eq "$actual" ]; then bar="${bar}π’π"
elif [ "$i" -eq "$ideal" ]; then bar="${bar}π’"
elif [ "$i" -eq "$actual" ]; then bar="${bar}π"
else bar="${bar}Β·"
fi
done
[ "$actual" -ge "$width" ] && bar="${bar}π"
echo "$bar"
}
if [ -n "$SEVEN_D_RESETS" ]; then
REMAINING_7D=$((SEVEN_D_RESETS - NOW))
RESET_7D_MD=$(date -r "$SEVEN_D_RESETS" "+%m/%d" | awk -F/ '{printf "%d/%d", $1, $2}')
RESET_7D_TAG="@${RESET_7D_MD}"
if [ "$REMAINING_7D" -gt 0 ] && [ "$REMAINING_7D" -lt 604800 ]; then
IDEAL_7D=$(( (604800 - REMAINING_7D) * W7 / 604800 ))
else
IDEAL_7D=0
fi
else
IDEAL_7D=0; RESET_7D_TAG="@?"
fi
ACTUAL_7D=$(awk "BEGIN {print int($SEVEN_D_PCT * $W7 / 100)}")
BAR_7D=$(make_bar "$ACTUAL_7D" "$IDEAL_7D" "$W7")
[ "$ACTUAL_7D" -gt "$IDEAL_7D" ] && WARN_7D=" β οΈ" || WARN_7D=""
SEVEN_D_DISP=$(printf "%.0f" "$SEVEN_D_PCT")
if [ -n "$FIVE_H_RESETS" ]; then
REMAINING_5H=$((FIVE_H_RESETS - NOW))
RESET_5H_JST=$(date -r "$FIVE_H_RESETS" "+%H:%M")
RESET_5H_TAG="@${RESET_5H_JST}"
if [ "$REMAINING_5H" -gt 0 ] && [ "$REMAINING_5H" -lt 18000 ]; then
IDEAL_5H=$(( (18000 - REMAINING_5H) * W5 / 18000 ))
else
IDEAL_5H=0
fi
else
IDEAL_5H=0; RESET_5H_TAG="@?"
fi
ACTUAL_5H=$(awk "BEGIN {print int($FIVE_H_PCT * $W5 / 100)}")
BAR_5H=$(make_bar "$ACTUAL_5H" "$IDEAL_5H" "$W5")
[ "$ACTUAL_5H" -gt "$IDEAL_5H" ] && WARN_5H=" β οΈ" || WARN_5H=""
FIVE_H_DISP=$(printf "%.0f" "$FIVE_H_PCT")
USAGE_CACHE=~/.claude/usage-cache.json
USAGE_ATTEMPT=~/.claude/.usage-fetch-attempt
LAST_ATTEMPT=$(cat "$USAGE_ATTEMPT" 2>/dev/null || echo 0)
if [ $((NOW - LAST_ATTEMPT)) -ge 300 ]; then
(nohup ~/.claude/usage-fetch.sh >/dev/null 2>&1 &) 2>/dev/null
fi
SCOPED=$(jq -r --arg model "$MODEL" --argjson now "$NOW" '
def pace($b): if ($b.resets_at // 0) > $now and ($b.resets_at - $now) < 604800
then (604800 - ($b.resets_at - $now)) * 100 / 604800 else 100 end;
def seg($b): " | \($b.name) 7d:\($b.pct | round)%"
+ (if $b.pct > pace($b) and $b.pct >= 25 then " \u26a0\ufe0f" else "" end);
(.buckets // []) as $bs
| ($model | ascii_downcase) as $m
| ($bs | map(select(.name as $n | $m | startswith($n | ascii_downcase))) | first) as $mine
| (if $mine then seg($mine) else "" end)
+ ($bs | map(select(.name != ($mine.name // "") and .pct > pace(.) and .pct >= 25))
| map(seg(.)) | join(""))
' "$USAGE_CACHE" 2>/dev/null) || SCOPED=""
echo "[${MODEL}] ${BAR_7D} 7d(all):${SEVEN_D_DISP}%${WARN_7D}${RESET_7D_TAG} | ${BAR_5H} 5h:${FIVE_H_DISP}%${WARN_5H}${RESET_5H_TAG}${SCOPED}"
Notes on the new block:
(nohup β¦ &) in a subshellFable 5.1, the bucket is named Fable; startswith on lowercased names connects them.pace() is the tortoise in percentSCOPED is empty and the line looks exactly like the previous version.
The scripts are written for macOS: the token is read from the keychain with security, and statusline.sh uses BSD date -r <epoch> (on Linux, use date -d @<epoch>). They also need jq and curl.
Put both scripts in ~/.claude/ and make them executable:
chmod +x ~/.claude/statusline.sh ~/.claude/usage-fetch.sh
In settings.json, the status line still points only at statusline.sh. Add refreshInterval (why is explained below):
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"refreshInterval": 60
}
}
This is not a documented API. It's what Claude Code itself calls, it can change without notice, and it isn't clear whether calling it from your own script is an intended use. The script fails silently, so if the endpoint changes or stops answering, the rest of the status line keeps working.
Claude Code doesn't run the status line command on a timer. It runs it on events: at session start, whenever a new assistant message arrives, after /compact, when the permission mode changes, and a few others (docs). Updates are debounced at 300ms, and if a new update comes in while the script is still running, the running one is cancelled.
While an agent is working, that means a render for every message β far too often for an HTTP request. That's why the fetch is a separate script behind the 5-minute check, launched detached so it isn't killed when the next render cancels statusline.sh. A new value shows up on the render after the fetch finishes.
Without refreshInterval, there's one request to the usage endpoint roughly every 5 minutes while agents are producing messages, and none when no messages are arriving. The attempt timestamp and the lock are files under ~/.claude/, so the 5-minute gap is shared across all your Claude Code sessions, not per session.
The catch is the idle case. When the main session is idle β say, waiting on background subagents β the event triggers go quiet, and the Fable number stops updating while the subagents keep burning it. That's why the setup adds refreshInterval: it re-runs the command every N seconds on top of the events, so with 60 the line re-renders every minute. The trade-off is that the request now also happens while you're idle β still at most once every 5 minutes, as long as a Claude Code session is open.