DEV Community

Lily
Lily

Posted on Originally published at dev.to

A Blank Dashboard and a Fake 2.5M-Token Reading: Building a Token Fuel Gauge for Claude Code

A side hustle I started in college went from ¥100k a month to ¥600k once I was running several at the same time. A layoff took it to zero. Then I spent six months building an autonomous setup around Claude Code, and today it runs at ¥1.2M a month in revenue. The core of that setup isn't "writing code" — it's stacking up mechanisms that let me notice things before they fall apart. This post opens up the complete wiring of one of them: a "token fuel gauge" that warns me before a 5-hour block burns out.


Why this setup works

What Claude Code's 5-hour blocks are

Claude Code (MAX plan) has usage blocks that reset every 5 hours. The problem is that consumption is invisible from the outside.

There's no remaining-capacity bar like the browser version has. When you're running a Claude Code session in a terminal, nothing on screen changes as you approach the ceiling of the 5-hour block. Responses don't get slower, and no error appears. It's just that output quality quietly, gradually degrades.

I noticed this one night when I asked for a refactor of an automation script. Same prompt, but the output was clearly thinner than when I'd run it that morning. Parts of the code were abbreviated, and error handling had dropped out. Checking later with ccusage, that session's output tokens had already passed 800k. Right on the edge of the 800k threshold.

The problem was that I had no way to know that in real time, while working.

Solve it with the environment, not with more effort

The more conscientious you are, the more you think "I'll just be more careful about how I use it." But that mindset can't beat the mechanics. The 5-hour count accumulates unconsciously, and the more focused you are, the faster it burns.

I chose the opposite approach: automate the monitoring and embed the state permanently in the status line. That drops the cognitive cost of "checking how much is left" to zero. You don't have to look on purpose — it's enough that a number sits somewhere your eyes pass over.

This is the same design philosophy as a fuel gauge. Nobody pops the hood and measures the oil level every time they drive. There's a gauge on the dashboard, so a glance is enough to make a judgment. Claude Code's 5-hour block just needs the same structure.

What you get from this

Here are the three things we build in this article.

  • token-budget-advisor.sh: a script that aggregates token consumption, weekly cost, and session count from two sources — ccusage and cost-log.jsonl — and emits a three-level verdict (🟢 ok / 🟡 warn / 🔴 critical)
  • --short mode: a one-line summary output dedicated to status-line embedding
  • Integration into dashboard.sh: calling it from the existing dashboard-update script so it's automatically folded into your morning situational check

Once it's done, every time you open a terminal you'll see something like this in the status line.

budget: 🟢 OK (5h:312k tok $1.2 / 7d:$48)
Enter fullscreen mode Exit fullscreen mode

Or the color changes automatically as you approach the ceiling.

budget: 🟡 burst (5h:843k tok $3.1 / 7d:$92)
budget: 🔴 cap-near (5h:1231k tok $4.8 / 7d:$134)
Enter fullscreen mode Exit fullscreen mode

The moment that enters your field of vision, the decision to "push the heavy work into the next block" becomes natural.


The overall flow

The data flow at a glance

This system picks up information from two data sources and consolidates them into a single script.

┌─────────────────────────────────────────────────────┐
│  データソース層                                       │
│                                                     │
│  ccusage blocks --json ──────────────────┐          │
│  (アクティブブロックの公式出力トークン数)    │          │
│                                           ├─► token-budget-advisor.sh
│  ~/.claude/logs/cost-log.jsonl ──────────┘          │
│  (session_id × transcript ごとの累積コスト)           │
└─────────────────────────────────────────────────────┘
                         │
              ┌──────────▼──────────┐
              │  判定エンジン (Python) │
              │                     │
              │  5h output tokens   │
              │  ├ ≥ 1,200,000 → 🔴 critical
              │  ├ ≥   800,000 → 🟡 warn    
              │  └ <   800,000 → 🟢 ok      
              │                     │
              │  7d cost (USD)      │
              │  └ ≥ $3,000  → 🟡 warn      
              │                     │
              │  直近3日 セッション数  │
              │  └ 平均 > 5/day → burst     
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │  出力モード           │
              │                     │
              │  (引数なし) → JSON詳細 │
              │  --short   → 1行サマリ │
              └──────────┬──────────┘
                         │
              ┌──────────▼──────────┐
              │  dashboard.sh       │
              │  (日次自動更新)     │
              │                     │
              │  ## 💰 Cost (7d)    │
              │    budget: [--short] │
              └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Why there are two data sources

This is an important implementation decision.

ccusage blocks --json is active-block data output by Claude Code's official CLI tool. It reflects the token count of the currently running block most accurately. However, you can't get data in environments where ccusage isn't installed, or when no block is active.

~/.claude/logs/cost-log.jsonl is the cost log Claude Code generates automatically. For each combination of session ID and transcript, it records the cumulative cost and output token count. Since this doesn't depend on ccusage, it always works as a fallback.

The script's implementation has this priority order.

# ccusage が居れば 5h block の output token を取る (transcript 計算より公式)
CC_OUTPUT_TOK=""
CC_COST_5H=""
if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
  if [ -n "$CC_JSON" ]; then
    EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    active = [b for b in d.get('blocks', []) if b.get('isActive')]
    if active:
        b = active[0]
        tc = b.get('tokenCounts', {}) or {}
        out = int(tc.get('outputTokens', 0))
        cost = float(b.get('costUSD', 0))
        print(f'{out}|{cost}')
    else:
        print('|')
...
Enter fullscreen mode Exit fullscreen mode

It pulls out only the active block and receives outputTokens and costUSD pipe-delimited. Then, when combining with the aggregation result from cost-log.jsonl, the ccusage values take priority (lines 127–131 of the code).

# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)
own_out_5h = out_5h
if cc_out is not None and cc_out > 0:
    out_5h = cc_out
if cc_cost is not None and cc_cost > 0:
    cost_5h = cc_cost
Enter fullscreen mode Exit fullscreen mode

The result of this design is a fallback structure that works even if either data source is missing. If ccusage isn't usable, aggregation runs on cost-log.jsonl alone and the script exits 0 (fail-open policy).

How to read cost-log.jsonl correctly

This file has one trap: multiple lines are recorded for the same session. Because Claude Code writes to the log incrementally during a session, many intermediate aggregates remain from before the final token count was settled. Naively summing all lines causes double counting.

The "only take the last line" logic below avoids that (lines 100–111 of the code).

# cost-log は session_id × transcript ごとに累積値で書かれる仕様。
# 最新行のみ採用するため、(session_id, transcript) で最終行を取り直す。
latest = {}
with open(log_path) as f:
    for line in f:
        try:
            r = json.loads(line)
            t = datetime.datetime.fromisoformat(r["ts"])
        except Exception:
            continue
        key = (r.get("session_id", ""), r.get("transcript", ""))
        prev = latest.get(key)
        if (prev is None) or (t > prev[0]):
            latest[key] = (t, r)
Enter fullscreen mode Exit fullscreen mode

It builds a dictionary keyed on the (session_id, transcript) pair and keeps overwriting whenever a line has a newer timestamp. By the time the loop ends, what's left in latest is only the final settled value for each session/transcript.

The values extracted through this aggregation are what feed the three-level threshold check.

The three-level decision logic

There are four thresholds (lines 139–142 of the code).

THRESH_5H_WARN      = 800_000    # output tokens
THRESH_5H_CRIT      = 1_200_000
THRESH_WEEK_WARN    = 3000       # USD
THRESH_SESS_PER_DAY = 5
Enter fullscreen mode Exit fullscreen mode

Output tokens in the 5-hour block go to warn past 800k and critical past 1.2M. Weekly cost goes to warn past $3,000. On top of that, if the session average over the last 3 days exceeds 5 per day, a burst flag is raised.

if out_5h >= THRESH_5H_CRIT:
    s5 = "critical"
elif out_5h >= THRESH_5H_WARN:
    s5 = "warn"
else:
    s5 = "ok"

# 集中作業判定: 直近 3 日で平均 > 5 sess/day
recent_days = sorted(by_day.keys())[-3:]
avg_sess = sum(by_day[d] for d in recent_days) / max(1, len(recent_days))
burst = avg_sess > THRESH_SESS_PER_DAY
Enter fullscreen mode Exit fullscreen mode

burst functions as a "burnout forecast." Even when the absolute token count is still under the threshold, a high session frequency means consumption is that much faster. It works as a leading signal: you're fine now, but there's a good chance you'll cross into warn before the night is out.

--short mode and folding it into dashboard.sh

The detailed JSON output is handy while debugging, but it's far too long to embed in a status line. Pass the --short argument and you get a one-line summary.

result = {
    ...
    "_short": f"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})",
}
Enter fullscreen mode Exit fullscreen mode

The icon evaluates the 5-hour status with top priority (lines 173–181 of the code).

if s5 == "critical":
    icon, label = "🔴", "cap-near"
elif s5 == "warn" or sw == "warn":
    icon, label = "🟡", "burst"
elif burst:
    icon, label = "🟡", "burst"
else:
    icon, label = "🟢", "OK"
Enter fullscreen mode Exit fullscreen mode

Running in --short mode gives output like this.

🟢 OK (5h:312k tok $1.2 / 7d:$48)
Enter fullscreen mode Exit fullscreen mode

dashboard.sh pulls that single line in like so (line 79 of the script).

echo "## 💰 Cost (7d)"
~/.claude/scripts/cost-summary.sh --short
echo "  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
Enter fullscreen mode Exit fullscreen mode

dashboard.sh runs daily via cron and auto-updates ~/.claude/dashboard.md. In other words, the next time you open the dashboard, yesterday's fuel-consumption summary has already been written into it. The morning after a heavy session, one look at the dashboard tells you "yesterday went all the way to warn."

Real-time status-line integration is covered in more detail in the next chapter.

Implementation details — why it's written that way

Combining fail_open with set -u

A single line at the top of the script packs in the whole design philosophy.

set -u  # -e は外す: fail-open 方針
Enter fullscreen mode Exit fullscreen mode

Dropping -e (exit immediately on error) is intentional. On line 79, dashboard.sh calls the script inside a command substitution like this.

echo "  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
Enter fullscreen mode Exit fullscreen mode

If a subcommand inside a command substitution exits 1, under set -e the calling shell itself dies. dashboard.sh runs every morning via cron and updates several sections at once — Health, Cost, Hook latency, and more. Having the entire dashboard go blank every time token-budget-advisor.sh dies from a ccusage path mismatch or a missing log is a problem.

So I set up fail_open().

fail_open() {
  if [ "$MODE" = "--short" ]; then
    echo "⚫ n/a"
  else
    printf '{"5h_status":"unknown","weekly_status":"unknown","advice":"%s"}\n' "${1:-no data}"
  fi
  exit 0
}

[ -f "$LOG" ] || fail_open "cost-log.jsonl not found"
Enter fullscreen mode Exit fullscreen mode

In --short mode it prints ⚫ n/a and finishes with exit 0. The dashboard shows budget: ⚫ n/a, but the state "data couldn't be fetched" remains on the page as text. That's far easier to debug than a silent blank.

Triple-layered defense around the ccusage call

if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
  if [ -n "$CC_JSON" ]; then
    EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
...
" 2>/dev/null || echo "|")
    CC_OUTPUT_TOK="${EXTRACTED%|*}"
    CC_COST_5H="${EXTRACTED#*|}"
  fi
fi
Enter fullscreen mode Exit fullscreen mode

There are three layers.

Layer 1: existence check with command -v ccusage >/dev/null 2>&1. In a launchd environment, PATH is only /usr/bin:/bin:/usr/sbin:/sbin, so ccusage under nvm isn't visible. Skipping here means nothing after it is touched at all.

Layer 2: ccusage blocks --json 2>/dev/null || true. This covers the case where ccusage exists but spits out some error (bad JSON, network problems). || true guarantees exit 0, and CC_JSON becomes an empty string.

Layer 3: python3 -c "..." 2>/dev/null || echo "|". Even if the Python parse fails, it returns the fallback string |. Because the following bash parameter expansions "${EXTRACTED%|*}" and "${EXTRACTED#*|}" split on the pipe delimiter, a bare | makes both empty strings, which is treated the same as ccusage not being used.

The reason for splitting with parameter expansion instead of using something like python3 -m json.tool is to shave off one subshell. If this gets embedded in a status line, the call frequency could get high, so I stack up small efficiencies.

Passing values into Python and the isdigit() check

The bash→Python bridge goes through sys.argv.

RESULT=$(python3 - "$LOG" "${CC_OUTPUT_TOK:-}" "${CC_COST_5H:-}" <<'PY' 2>/dev/null
import sys, json, datetime, collections

log_path, cc_out_str, cc_cost_str = sys.argv[1], sys.argv[2], sys.argv[3]
cc_out = int(cc_out_str) if cc_out_str.isdigit() else None
try:
    cc_cost = float(cc_cost_str) if cc_cost_str else None
except ValueError:
    cc_cost = None
Enter fullscreen mode Exit fullscreen mode

${CC_OUTPUT_TOK:-} is the pattern for expanding an undefined variable to an empty string under set -u. In environments where ccusage isn't installed, CC_OUTPUT_TOK stays undefined, so without this the script dies with unbound variable.

cc_out_str.isdigit() rejects empty strings, decimals, negative values, and the string None all in one shot. Passing an empty string to int() raises ValueError, so you'd need try/except — but for an integer check, isdigit() fits in one line. cc_cost is handled with try/except ValueError because ccusage returns decimals like "0.001234".

The latest dictionary and "why two passes"

Reading the code, cost-log.jsonl gets opened twice. There's a first pass and a second pass.

# 1パス目
with open(log_path) as f:
    for line in f:
        ...
        if t >= cutoff_5h:
            pass  # ← 実際には何もしない
        if t >= cutoff_7d:
            day = t.strftime("%Y-%m-%d")
            sess_7d_by_day[day].add(sid)
Enter fullscreen mode Exit fullscreen mode

The first pass is now essentially dead code. It builds sess_7d_by_day, but downstream it's the by_day Counter (updated in the second pass) that actually gets used. It's leftover code from the implementation process.

What's effective is the latest dictionary in the second pass (lines 100–124 of the code).

latest = {}
with open(log_path) as f:
    for line in f:
        try:
            r = json.loads(line)
            t = datetime.datetime.fromisoformat(r["ts"])
        except Exception:
            continue
        key = (r.get("session_id", ""), r.get("transcript", ""))
        prev = latest.get(key)
        if (prev is None) or (t > prev[0]):
            latest[key] = (t, r)

for (sid, _tr), (t, r) in latest.items():
    out = int(r.get("output", 0))
    cost = float(r.get("cost_usd", 0))
    if t >= cutoff_5h:
        out_5h  += out
        cost_5h += cost
        n_5h    += 1
Enter fullscreen mode Exit fullscreen mode

Keyed on (session_id, transcript), it keeps overwriting whenever a line's timestamp is newer. After the loop ends, iterating latest.items() walks only the final settled value for each session/transcript.

Why this is necessary: because Claude Code writes JSONL incrementally during a session. Every time the same transcript in the same session grows "8,000 → 18,400 → 29,700 → 44,100 tokens," a line is appended with the cumulative value at that point. Naively summing all lines gives 8,000+18,400+29,700+44,100 = 100,200, but the correct consumption is the final value, 44,100.

Why diff_pct doesn't show up in the output

The result dictionary has a source_diff_pct field.

diff_pct = None
if cc_out is not None and own_out_5h > 0:
    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)

result = {
    ...
    "source_diff_pct": diff_pct,
    "ccusage_used": cc_out is not None,
    ...
}
Enter fullscreen mode Exit fullscreen mode

It doesn't appear in --short mode, but it's included in the detailed JSON output. It records, as a percentage, the divergence between the ccusage-derived token count and the cost-log.jsonl-derived one.

If this keeps exceeding 20%, that's a sign that one of the data sources is broken or that ccusage's data structure has changed. In normal operation you never see it, but when something feels off about the numbers, running token-budget-advisor.sh manually (no arguments) prints the detailed JSON, and this value tells you which source to suspect.

Concatenating advice and the _short format

The advice field joins everything together when multiple flags are raised (lines 161–171 of the code).

advice_parts = []
if s5 == "critical":
    advice_parts.append(f"5h output {out_5h/1000:.0f}k超過: 一旦休憩推奨")
elif s5 == "warn":
    advice_parts.append(f"5h output {out_5h/1000:.0f}k接近: 重い作業は次ブロックへ")
if sw == "warn":
    advice_parts.append(f"7d cost ${cost_7d:.0f}: MAX定額枠の消費過多")
if burst:
    advice_parts.append(f"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中")
if not advice_parts:
    advice_parts.append("budget healthy")
Enter fullscreen mode Exit fullscreen mode

When "5h is warn AND weekly is also warn AND burst" overlap, advice lists three items separated by slashes. Grepping the detailed-JSON-mode logs afterward tells you how often those compound states occur.

The _short format rounds to thousands with {out_5h/1000:.0f}k tok (line 196 of the code).

"_short": f"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})",
Enter fullscreen mode Exit fullscreen mode

:.0f displays an integer with the decimals truncated. 312000 → 312k reads much better. For cost display, the 5h figure has one decimal place and the weekly one is an integer, evening out the visual information density.


Where I got stuck

Stumble 1: the dashboard went blank every morning

The first version had set -eo pipefail in it.

One morning I opened ~/.claude/dashboard.md and the contents were empty. The mtime was from that morning, but the file size was 0 bytes.

launchd jobs only have /usr/bin:/bin:/usr/sbin:/sbin on PATH. ccusage, installed via nvm, lives at ~/.nvm/versions/node/v24.13.0/bin/ccusage, which isn't on the path in a launchd environment. ccusage blocks --json returned exit 127 with command not found, and under -e the script died instantly.

dashboard.sh's command substitution $( token-budget-advisor.sh --short ) propagated that exit code, the whole redirect block {...} > "$OUT" was cancelled, and OUT became 0 bytes.

The fix came in two steps.

# 修正前
set -eo pipefail
...
CC_JSON=$(ccusage blocks --json)  # ccusage がなければ exit 127 → 即死

# 修正後
set -u  # -e を外す
...
CC_JSON=$(ccusage blocks --json 2>/dev/null || true)  # 失敗しても exit 0、CC_JSON は空文字
Enter fullscreen mode Exit fullscreen mode

Ending fail_open() with exit 0 is the design I derived from this experience. There are still days when the single line budget: ⚫ n/a shows up on the dashboard, but that's meaningful information — "there was a day ccusage couldn't be read" — and it's far easier to debug than a blank page.

Stumble 2: an absurd reading of 2.5M tokens in 5 hours

The first implementation didn't use the latest dictionary; it just summed every line.

# 危険な初期実装
with open(log_path) as f:
    for line in f:
        r = json.loads(line)
        t = datetime.datetime.fromisoformat(r["ts"])
        if t >= cutoff_5h:
            out_5h += int(r.get("output", 0))  # 全行合算
Enter fullscreen mode Exit fullscreen mode

One night, after a long stretch of heavy work, the --short output showed 🔴 cap-near (5h:2541k tok...). The threshold is 1.2M, so 2.5M is physically impossible. It exceeds the MAX plan's ceiling.

Opening cost-log.jsonl directly, there were 30-plus lines with the same session_id reading "output": 11200, "output": 23800, "output": 39500, and so on. I'd been adding up every cumulative value Claude Code writes incrementally during a session.

After fixing it to group by (session_id, transcript) and take only the last line, the same session read 🟡 burst (5h:843k tok...). That was the correct number.

This experience confirmed that the output field in cost-log.jsonl is a cumulative value, not a delta. You can't guess that from the filename — it's a bug that only surfaces once you run it against real data.

Stumble 3: broken emoji put a mystery string in the status line

I'd forgotten to add ensure_ascii=False to the Python output.

# 危険な初期実装
print(json.dumps(result))  # ensure_ascii=False なし
Enter fullscreen mode Exit fullscreen mode

Here's the kind of string that came out of --short mode.

🟢 OK (5h:312k tok $1.2 / 7d:$48)
Enter fullscreen mode Exit fullscreen mode

The 🟢 (U+1F7E2) had become a surrogate-pair escape. Print that to a terminal and, depending on how zsh handles the string, you either get \ud83d displayed literally as characters, or the prompt-width calculation goes off and the cursor position breaks.

# 修正後
print(json.dumps(result, ensure_ascii=False))
Enter fullscreen mode Exit fullscreen mode

Python 3's default is ensure_ascii=True (escaping non-ASCII characters as \uXXXX). Japanese advice strings break the same way. ensure_ascii=False is a mandatory specification for JSON serialization that handles emoji or Japanese.

Stumble 4: not checking isActive piled on the previous blocks too

ccusage blocks --json comes back with a structure like this.

{
  "blocks": [
    { "isActive": true, "tokenCounts": { "outputTokens": 412000 }, "costUSD": 1.52 },
    { "isActive": false, "tokenCounts": { "outputTokens": 980000 }, "costUSD": 3.61 },
    { "isActive": false, "tokenCounts": { "outputTokens": 542000 }, "costUSD": 2.01 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At first I wasn't filtering on isActive and was summing outputTokens across all blocks.

# 危険な初期実装
d = json.load(sys.stdin)
out = sum(b.get("tokenCounts", {}).get("outputTokens", 0) for b in d.get("blocks", []))
# → 412000 + 980000 + 542000 = 1,934,000 になる
Enter fullscreen mode Exit fullscreen mode

It added in past blocks too, so it always came out critical.

The fix pulls out only the active block (lines 41–47 of the code).

active = [b for b in d.get('blocks', []) if b.get('isActive')]
if active:
    b = active[0]
    tc = b.get('tokenCounts', {}) or {}
    out = int(tc.get('outputTokens', 0))
    cost = float(b.get('costUSD', 0))
    print(f'{out}|{cost}')
else:
    print('|')
Enter fullscreen mode Exit fullscreen mode

The doubled {} in tc = b.get('tokenCounts', {}) or {} is also worth a look. When tokenCounts comes back as null (right after a block starts, for instance), get() returns None. None or {} becomes {}, so the following .get("outputTokens", 0) doesn't crash. A get() default alone can't prevent the nullNone case, so or {} is necessary.

Stumble 5: a no-argument call crashed under set -u

I originally wrote the --short mode check like this.

# 危険な初期実装
if [ "$1" = "--short" ]; then
  MODE="--short"
fi
Enter fullscreen mode Exit fullscreen mode

set -u exits 1 immediately when an undefined variable is referenced. Calling token-budget-advisor.sh with no arguments produced the error $1: unbound variable and died.

# 修正後
MODE="${1:-json}"
Enter fullscreen mode Exit fullscreen mode

${1:-json} uses json as the default value when $1 is undefined or empty. A no-argument call becomes MODE=json and passing --short becomes MODE=--short, which coexists with set -u.

Since I also aligned the subsequent checks to [ "$MODE" = "--short" ], every reference to $1 disappeared from the script. Small defenses like this are bugs you don't notice until "it suddenly dies in production cron."


Most of these stumbles only surfaced by "building a version that runs first, then running it against real files." Even if you verify the threshold logic with unit tests, you can't catch the cost-log.jsonl double-counting problem until you feed it a real file. The launchd PATH problem doesn't reproduce until you register it with cron and run it for the first time.

There are bugs you can only see with real data and a real environment. The structure that keeps you from leaving those to "it should work" — fail-open, ⚫ n/a in --short, the source_diff_pct debug info — stacked up, and now the daily dashboard runs without ever going blank.

To build "a mechanism that notices before the environment falls apart," you first crush every place you personally got stuck. That's the unglamorous core of maintaining a ¥1.2M/month autonomous setup.

Additional gotchas

The previous chapter went through five stumbles in detail with real code (blank dashboard, the 2.5M-token anomaly, broken emoji, no isActive filter, the set -u no-argument crash). Here I'll list the additional gotchas I actually hit, in bullet form. These are mostly ones that surfaced after going into operation.

Forgetting the single quotes on the heredoc EOF

The main aggregation section embeds the Python script in bash with a <<'PY' heredoc. At first I wrote <<PY (no quotes). Do that and bash expands variables inside the document. For instance, even if you've written log_path = sys.argv[1] in your Python code, the moment a $HOME appears inside the heredoc, bash replaces it with the home directory path. The script works syntactically, but you discover the problem — a hardcoded path — when you run it on a different machine. The single quotes in <<'PY' disable bash's variable expansion completely. The rule in this script is to unify bash→Python value passing on sys.argv alone, so there's no need whatsoever for variables inside the heredoc.

How the first pass became dead code

Reading the actual script, cost-log.jsonl gets opened twice (the first pass on lines 80–96, and the second pass on lines 100–111). Inside the first pass is this comment.

if t >= cutoff_5h:
    # transcript 同一の場合は最新行で上書き集計したい → ここは単純合算で OK
    # (cost-log は session ごとに累積値で書かれているので、最新行のみ採用すべき)
    pass
Enter fullscreen mode Exit fullscreen mode

It's pass. It does nothing. I initially tried to do "take only the last line" in a single pass, but you can't know "whether this line is the last one" until you read the next line. To keep overwriting during a scan, "last" isn't settled until you've read the whole file. So a second pass became necessary, and the first pass was left with only the code that aggregates session counts into sess_7d_by_day. But what ultimately gets used is the by_day Counter updated in the second pass, and sess_7d_by_day isn't used either. The evolution of the implementation is left in the code as a fossil.

A timezone naive/aware collision silently skips every line

If cost-log.jsonl's ts field is in a timezone-bearing format like 2026-08-02T05:12:33+00:00, datetime.datetime.fromisoformat(r["ts"]) returns a tz-aware datetime. Meanwhile, the aggregation reference time is computed like this.

now = datetime.datetime.now()
cutoff_5h  = now - datetime.timedelta(hours=5)
Enter fullscreen mode Exit fullscreen mode

datetime.now() is tz-naive. In the t >= cutoff_5h comparison, naive and aware collide, and on Python below 3.11 you get TypeError: can't compare offset-naive and offset-aware datetimes. But because this code sits inside try/except Exception: continue, the exception never reaches the console and the line is simply skipped. If every line is skipped, out_5h=0 stays as-is and processing finishes with a normal value (zero tokens) rather than cost-log.jsonl not found. The output becomes 🟢 OK (5h:0k tok $0.0 / 7d:$0) — the hardest bug to notice, appearing as the phenomenon "for some reason the cost is zero."

An assumption about launchd job names broke

Line 38 of dashboard.sh has this code.

launchctl list | grep com.shun | awk '{printf "- %s exit=%s\n", $3, $2}' | head -15
Enter fullscreen mode Exit fullscreen mode

It's a grep that assumes launchd jobs are created with the com.shun.* naming convention. Jobs created with a different convention don't show up at all. There were days when the dashboard's "Scheduled Jobs" section showed only one entry, and I misread it as "the jobs are gone." In reality the grep pattern just didn't match the job names. Since launchctl's listing puts the canonical job name in the Label column, you need to change the pattern to match your own environment's job naming convention.

Single quotes collide inside python3 -c

The ccusage parsing section (lines 37–52 of the script) uses the form printf '%s' "$CC_JSON" | python3 -c "...". The reason you can use Python single quotes inside "..." is that the outer quoting is double quotes.

EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
import sys, json
d = json.load(sys.stdin)
active = [b for b in d.get('blocks', []) if b.get('isActive')]
...
" 2>/dev/null || echo "|")
Enter fullscreen mode Exit fullscreen mode

The single quotes in d.get('blocks', []) don't terminate the bash string. That's because I chose the approach of passing JSON via stdin. Had I written -c 'import sys...' directly, the internal Python single quotes would terminate the bash string and cause a syntax error. I choose between printf ... | python3 -c "..." and python3 - <<'PY' ... PY based on whether the script is short or long.

Status-line integration cost 500ms on every Enter

At first I put the command substitution directly in zsh's PROMPT.

PROMPT='%F{blue}%~%f $(~/.claude/scripts/token-budget-advisor.sh --short) %# '
Enter fullscreen mode Exit fullscreen mode

The script runs every time you press Enter. Python startup (about 80ms) + reading cost-log.jsonl (50–200ms depending on line count) + the ccusage call (200–400ms) stacked up, and in sessions with heavy work the wait exceeded a perceptible 500ms. The solution is to switch to letting dashboard.sh handle it. dashboard.sh runs daily via cron and updates ~/.claude/dashboard.md (line 104 of dashboard.sh does cat "$OUT"). Putting a one-line command in the status line that reads that cache is much lighter. Alternatively, you can put it in tmux's status-right with a 30-second update interval.

I only noticed once source_diff_pct went past 20%

It doesn't appear in the normal --short output, but running with no arguments prints a value like "source_diff_pct": 23.4 in the detailed JSON.

diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)
Enter fullscreen mode Exit fullscreen mode

It's the divergence rate between the ccusage-derived and cost-log.jsonl-derived token counts. One day, quality felt degraded even though I shouldn't have been over the threshold. Running manually with no arguments, source_diff_pct was 28.1. The cause was that ccusage's data structure had changed subtly in the previous update — a key name inside tokenCounts had changed. If source_diff_pct is near zero (within 5%), the two sources are consistent. Since I built a habit of checking it manually on a regular basis, I've been able to catch numeric drift early.


Best practices

I've now walked through the implementation and all the stumbles. Here are 15 practical rules I wished I'd known from the start after actually running this.

1. Write monitoring scripts fail-open

The worst pattern is the monitoring dying and taking the main thing with it. fail_open() finishes with exit 0 and, in --short mode, prints ⚫ n/a. Since it's used in the command substitution on line 79 of dashboard.sh, if the advisor dies the budget line becomes ⚫ n/a. A record saying "the day we couldn't fetch it" is easier to debug than a blank page. The point of fail-open isn't to swallow errors — it's to leave the state "fetch failed" behind as text.

2. Use only set -u and drop -e

set -u turns undefined variables into immediate errors and catches typos early. But adding -e means a failing external command terminates the entire script. For cron integration scripts, no -e is the right answer. dashboard.sh also uses set -uo pipefail (line 3), while advisor.sh uses only set -u (line 15). Even if the caller has -uo pipefail, as long as the callee returns exit 0, the whole command-substitution block survives.

3. Keep two data sources so it works when either is missing

The design lets it aggregate from cost-log.jsonl alone even in environments without ccusage. It doesn't break on a dev machine, a production machine, or a PATH-restricted launchd environment. When ccusage is available, it takes priority (lines 127–131 of the script). With a single-source dependency, the script dies every time the installation state changes.

4. Take only the last line per (session_id, transcript) key

The output in cost-log.jsonl is a cumulative value, not a delta. Every time the same session grows 11200 → 23800 → 39500 → 44100, a line is appended. Summing all lines gives 118,600, but the correct value is 44,100. Building the latest dictionary across two passes (lines 100–111) and using only the final settled values in the aggregation produces the correct number. Miss this and you'll always be in critical.

5. Fit integer validation in one line with isdigit()

Passing values between bash and Python lets empty strings, non-numerics, and None slip in.

cc_out = int(cc_out_str) if cc_out_str.isdigit() else None
Enter fullscreen mode Exit fullscreen mode

isdigit() rejects empty strings, decimals, negative values, and the string None. Passing an empty string to int() raises ValueError, so you'd need try/except — but for an integer check, isdigit() fits in a single line. Decimals (ccusage's costUSD is a string like "1.524") are handled with float() + try/except.

6. Always pair a get() default with or {}

b.get('tokenCounts', {}) returns an empty dict if the key is absent, but returns None if the key exists and the value is null. A get() default alone can't prevent nullNone.

tc = b.get('tokenCounts', {}) or {}
Enter fullscreen mode Exit fullscreen mode

Adding or {} converts None into an empty dict too. It prevents the following .get('outputTokens', 0) from throwing AttributeError in cases where tokenCounts comes back as null, such as right after a block starts.

7. Never forget ensure_ascii=False

Python 3's default is ensure_ascii=True. Both the 🟢 emoji (U+1F7E2) and Japanese advice strings become \uXXXX escapes. Cursor position shifts in terminal output, and a mystery string like 🟢 lines up in the status line. For JSON serialization containing emoji or Japanese, json.dumps(result, ensure_ascii=False) is a mandatory specification (line 199 of the script).

8. Make the ccusage call triple-layered

if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
  if [ -n "$CC_JSON" ]; then
    EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "..." 2>/dev/null || echo "|")
Enter fullscreen mode Exit fullscreen mode

Three layers: existence check (command -v) → error suppression (2>/dev/null || true) → parse-failure fallback (|| echo "|"). If PATH in a launchd environment is only /usr/bin:/bin:/usr/sbin:/sbin and ccusage isn't visible, layer 1 skips it. If ccusage exists but the JSON is malformed, layer 2 catches it; if the Python parse fails, layer 3 does.

9. Pull out only active blocks with an isActive filter

ccusage blocks --json returns an array that includes past blocks. Summing one active block (410k tokens) + two past blocks (1.52M tokens total) gives 1.93M, which always comes out critical.

active = [b for b in d.get('blocks', []) if b.get('isActive')]
Enter fullscreen mode Exit fullscreen mode

Narrow down to active blocks before extracting. Process only when an active block exists via if active:, and return the fallback with print('|') when it doesn't.

10. Consolidate --short mode and the detailed output in one script

Even though the format differs between status-line embedding and manual checking, splitting the script gives you two maintenance surfaces. When you change a threshold (800k / 1.2M / $3,000 / 5 sess/day), you update only one side and consistency breaks. Use MODE="${1:-json}" to make no-argument default to JSON, and in --short pull out only the _short field (lines 207–208). Because both outputs pass through the same decision engine, numeric consistency is guaranteed.

11. Continuously record data consistency with source_diff_pct

It doesn't appear in --short, but the detailed JSON output contains a value like "source_diff_pct": 4.2. It's the divergence rate between the ccusage-derived and cost-log.jsonl-derived token counts. Normally it stays within 5%. If it keeps exceeding 20%, that's a sign that ccusage's data structure changed or cost-log.jsonl's write format changed. When something feels off about the numbers, first run token-budget-advisor.sh manually (no arguments) and check this value.

12. Use a single-quoted EOF for heredocs: <<'EOF'

With <<PY, bash expands variables inside the heredoc. Unify bash→Python value passing on sys.argv and eliminate any need for bash variables inside the heredoc. With <<'PY', expansion is completely disabled and Python's literal strings arrive intact.

13. Use ${VAR:-} to turn undefined variables into empty strings under set -u

In environments without ccusage, CC_OUTPUT_TOK stays undefined. Referencing "$CC_OUTPUT_TOK" under set -u dies with unbound variable. ${CC_OUTPUT_TOK:-} turns both undefined and empty into "empty string." An empty string reaches sys.argv[2] on the Python side, cc_out_str.isdigit() returns False, and cc_out = None. Rather than swallowing an error, it propagates the state "there is no data" in a type-safe way.

14. Route status-line embedding through a cache

Putting a command substitution directly into zsh's PROMPT means it runs on every Enter. Python startup at 80ms + the file read + the ccusage call at 200–400ms stack up into a wait exceeding 500ms during heavy work sessions. Take advantage of the design where dashboard.sh runs daily via cron and updates ~/.claude/dashboard.md (line 104 of dashboard.sh), and keep the status line to a one-line command that reads the cache file. Putting it in tmux's status-right with a 30-second update interval also works.

15. Decide thresholds only after observing 1–2 weeks of real data

The numbers THRESH_5H_WARN = 800_000 / THRESH_5H_CRIT = 1_200_000 weren't fixed from the start. For one to two weeks I manually checked the detailed JSON output of token-budget-advisor.sh (no arguments), confirmed that output density perceptibly thins past 800k tokens, and only then adopted them as thresholds. The optimal values change with your own work patterns. If you're mostly asking light questions, the 5-hour block often resets naturally before you enter warn. The $3,000 weekly cost ceiling is also a number tuned to how I use the MAX plan's flat rate. The order matters: run it first, observe, then decide the numbers.


Wrap-up

token-budget-advisor.sh is a little over 200 lines of shell script plus inline Python, but packed into it is nearly every design decision needed to "keep a monitoring system running stably."

The first version I built didn't work. set -eo pipefail blanked the dashboard every morning, summing all lines produced a physically impossible 2.5M-token figure, and the emoji turned into escape strings.

The current implementation is the result of fixing those one by one. fail_open() came from the blank-dashboard experience. The latest dictionary came from the 2.5M-token anomaly. ensure_ascii=False came from the broken emoji. The triple-layered ccusage call came from the always-critical verdict caused by having no isActive filter. ${VAR:-} came from the no-argument crash. Every defense corresponds to a bug I actually hit.

What matters in this kind of script is less "the design while it's working" and more "the behavior when it breaks." If the monitoring system goes down, you can't notice quality degradation in what it monitors. If a single ⚫ n/a line comes out when it breaks, it remains as information: "we couldn't get data today." That's completely different from a blank page.

Just adding one line to line 79 of dashboard.sh means yesterday's fuel-consumption summary gets written into the morning dashboard automatically.

echo "  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
Enter fullscreen mode Exit fullscreen mode

The cognitive cost of checking token headroom mid-work went to zero. Instead of quality degrading without my noticing and me realizing the next morning that "yesterday's code looks sketchy," the decision to push heavy work into the next 5-hour block comes naturally.

A ¥1.2M/month autonomous setup runs not on flashy AI features but on an accumulation of unglamorous instruments like this one.


I've put the whole picture of the setup, the breakdown of the ¥1.2M, and a 30-day procedure into a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)