cd /news/ai-tools/and-in-my-claude-code-status-line-no… Β· home β€Ί topics β€Ί ai-tools β€Ί article
[ARTICLE Β· art-128064] src=dev.to β†— pub= topic=ai-tools verified=true sentiment=Β· neutral

🐒 and πŸ‡ in My Claude Code Status Line: Now Watching Fable

A developer extended their Claude Code status line to track per-model weekly quota buckets after discovering that the tool's built-in 7-day indicator only reflects the all-models limit, allowing Fable usage to hit 100% while the display showed 47%. The updated setup uses two scripts β€” statusline.sh and a new usage-fetch.sh β€” that poll Anthropic's undocumented OAuth usage endpoint every five minutes and cache per-model percentages for display, after the developer incurred roughly $200 in extra usage charges.

by read9 min views2 publishedSep 13, 2026

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.

── more in #ai-tools 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/and-in-my-claude-cod…] indexed:0 read:9min 2026-09-13 Β· β€”