DEV Community

yukihiro amadatsu
yukihiro amadatsu

Posted on

๐Ÿข and ๐Ÿ‡ in My Claude Code Status Line: Now Watching Fable

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.

Fable has its own weekly cap

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% โš ๏ธ
Enter fullscreen mode Exit fullscreen mode
  • 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:

  • The current model's bucket, always. On Fable 5.1 you always see Fable 7d:โ€ฆ.
  • Any other bucket, only when it's ahead of pace. In the line above I'm on Opus 5, but Fable shows up because 87% is well past where the week says it should be. That's the one that matters: the agents burning Fable aren't necessarily in the session you're looking at.

โš ๏ธ 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.

How it works

๐Ÿ“ฆ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

1. usage-fetch.sh (new)

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The script keeps only the per-model weekly buckets and caches them:

{"fetched_at":1789266899,"buckets":[{"name":"Fable","pct":87,"resets_at":1789549199}]}
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash
# Fetch per-model weekly quota buckets and cache them for statusline.sh.
set -u

CACHE=~/.claude/usage-cache.json
LOCK=~/.claude/.usage-fetch.lock
ATTEMPT=~/.claude/.usage-fetch-attempt

# Single flight. A lock older than 2min is stale (killed mid-fetch).
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

# Record the attempt first, so a failing fetch backs off like a successful one
# instead of re-firing on every render.
date +%s > "$ATTEMPT"

# OAuth token: keychain on macOS, credentials file elsewhere. Never written out.
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

# Keep only the weekly per-model windows. `percent` comes back as a 0-1
# fraction, but tolerate a real percentage in case that ever changes.
OUT=$(printf '%s' "$RESP" | jq -c --argjson now "$(date +%s)" '
    # resets_at arrives as "2026-09-16T09:00:00.126747+00:00" - fractional
    # seconds and an offset, neither of which jq fromdate accepts.
    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"
Enter fullscreen mode Exit fullscreen mode

A few details:

  • The token stays in memory. It's read from the macOS keychain (or ~/.claude/.credentials.json on other platforms), passed to curl, and unset. Only percentages and reset times are written to disk, with umask 077.
  • The attempt timestamp is written before the request. If the endpoint is down, the status line still backs off for 5 minutes instead of retrying on every render.
  • mkdir as a lock. It's atomic, so two sessions rendering at the same moment don't both fetch.
  • resets_at needs a hand-rolled parser. jq's fromdateiso8601 rejects both the fractional seconds and the +00:00 offset.
  • The write is tmp + mv, so statusline.sh never reads a half-written file.

2. statusline.sh (updated)

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"
}

# 7-day window (main bar, width=20)
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")

# 5-hour window (half bar, width=10)
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")

# Per-model weekly windows. Not in the payload - seven_day is the all-models
# bucket even while on Fable - so a detached helper caches them from the same
# endpoint /usage reads. Rendering never waits on it and stays silent on failure.
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

# Current model's own weekly bucket, plus any other bucket already past pace.
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}"
Enter fullscreen mode Exit fullscreen mode

Notes on the new block:

  • (nohup โ€ฆ &) in a subshell detaches the fetch completely, so Claude Code doesn't wait on it before drawing the line.
  • Model matching is a prefix match. The payload says Fable 5.1, the bucket is named Fable; startswith on lowercased names connects them.
  • pace() is the tortoise in percent โ€” how far through its 7-day window the bucket is. A bucket with no usable resets_at gets pace 100, so bad data only warns once the bucket is actually over 100%.
  • No cache, no segment. If the file is missing or unreadable, SCOPED is empty and the line looks exactly like the previous version.

3. Setup

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Use at your own risk

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.

When the request actually happens

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.

Top comments (0)