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.
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.
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.
Here are the three things we build in this article.
token-budget-advisor.sh
ccusage
and cost-log.jsonl
โ and emits a three-level verdict (๐ข ok / ๐ก warn / ๐ด critical)--short
modedashboard.sh
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)
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)
The moment that enters your field of vision, the decision to "push the heavy work into the next block" becomes natural.
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] โ
โโโโโโโโโโโโโโโโโโโโโโโ
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.
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('|')
...
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).
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
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).
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).
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)
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.
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
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"
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
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})",
}
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"
Running in --short
mode gives output like this.
๐ข OK (5h:312k tok $1.2 / 7d:$48)
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)"
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.
set -u
A single line at the top of the script packs in the whole design philosophy.
set -u # -e ใฏๅคใ: fail-open ๆน้
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)"
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"
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.
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
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.
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
${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"
.
Reading the code, cost-log.jsonl gets opened twice. There's a first pass and a second pass.
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)
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
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.
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,
...
}
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.
_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")
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})",
:.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.
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 ใฏ็ฉบๆๅญ
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.
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)) # ๅ
จ่กๅ็ฎ
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.
I'd forgotten to add ensure_ascii=False
to the Python output.
print(json.dumps(result)) # ensure_ascii=False ใชใ
Here's the kind of string that came out of --short
mode.
๐ข OK (5h:312k tok $1.2 / 7d:$48)
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))
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.
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 }
]
}
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", []))
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('|')
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 null
โ None
case, so or {}
is necessary.
set -u
I originally wrote the --short
mode check like this.
if [ "$1" = "--short" ]; then
MODE="--short"
fi
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}"
${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.
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:
pass
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)
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
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 "|")
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) %# '
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)
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.
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
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 null
โ None
.
tc = b.get('tokenCounts', {}) or {}
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 "|")
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')]
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.
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)"
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*