DEV Community

Lily
Lily

Posted on Originally published at dev.to

My Cost Monitor Said $234 When the Real Bill Was $48. Then set -e Made It Go Silent for a Week.

A monitoring script that dies quietly is worse than no monitoring at all. Mine proved it twice: first it reported a 7-day spend of $234 when ccusage daily said the real number was $48 (a 4–6x overcount from double-summing cumulative log lines, now down to a 2.3% source-to-source gap), and then, after I "hardened" it with set -euo pipefail, a single ccusage timeout made the whole thing exit 1 and my status bar sat blank for a week while two 5-hour blocks crossed critical without me noticing. The fix in both cases was the same design decision: fail open, print ⚫ n/a, and exit 0. This post walks through the one script — 212 lines — that runs my cost monitoring today, and the design principle behind it: never let the dashboard go quiet.

I went from ¥100k/month as a university student juggling side work up to ¥600k, dropped to zero after a company-side layoff, spent six months building an autonomous Claude Code environment, and now hold ¥1.2M/month in revenue. The core of that operating base is one rule: the dashboard must never stop.

Why this design matters

This isn't really a post about dashboards. It's a post about environments.

Once you start using Claude Code heavily, API cost management becomes a life-or-death issue. Even holding ¥1.2M/month in revenue, Claude Code's metered cost can blow past $3,000 in a single week if you take your eye off it. In the autonomous environment I built, launchd fires a cost-monitoring script every 30 minutes and the result is rendered into my status bar and terminal dashboard.

The problem is this: if the monitoring script dies, the whole dashboard dies with it.

set -euo pipefail looks robust. It gets recommended as a shell scripting best practice all the time. But the moment the 5-hour-window token calculation trips partway through, the status bar goes blank. The moment ccusage doesn't respond over the network, the launchd job terminates with an error. The moment the first automated run fires before cost-log.jsonl exists, the script dies on an exception.

That's the structural problem: the happy path is all green; what breaks is the error paths and the passage of time.

Design it fail-closed — meaning set -e turns every error into script termination — and the dashboard goes silent on all of those error paths. Silence looks like "no problems." That's the worst possible UI. You lose the ability to distinguish normal operation from missing data.

Design it fail-open, and the error paths still display ⚫ n/a. "No data" and "healthy" look different. You glance at the dashboard and immediately know something is off. That's the crux of dashboard design.

The property you need from a monitoring script isn't accuracy. It's never going quiet.

When you're mass-producing personal projects as a side business, an autonomous environment running alongside AI is a productivity multiplier. But if that environment itself isn't monitored, it keeps running while broken. You blow past a $3,000 weekly threshold with the cost anomaly invisible. A fail-open monitoring script is your environment's autoimmune system.

I suspect most readers stop at "I wrote a script and it runs." I did too. But once you hit the mass-production phase, "it worked at first and then broke without me noticing" happens constantly. Nothing is more harmful than broken monitoring — that's the angle here.

The overall flow

Script architecture

~/.claude/scripts/token-budget-advisor.sh is a 212-line bash script that calls Python3 internally — a mixed-language setup. The file looks long, but the structure is simple.

token-budget-advisor.sh
│
├─ [前処理] set -u のみ (-e は外す・fail-open方針)
│
├─ [データ源①] ccusage blocks --json   ← 公式カウント (優先)
│       │
│       └─ 取得失敗 → CC_OUTPUT_TOK="" のまま続行 (fail-open)
│
├─ [データ源②] $HOME/.claude/logs/cost-log.jsonl   ← 自前ログ
│       │
│       └─ ファイル不在 → fail_open() → exit 0
│
├─ [集計] Python3 heredoc
│       ├─ 5hウィンドウ: session dedup + ccusage優先マージ
│       ├─ 7dウィンドウ: weekly cost集計
│       └─ 直近3d burst判定 (avg > 5 sess/day)
│
├─ [判定] 🟢 OK / 🟡 warn / 🔴 critical
│
└─ [出力]
        ├─ --short モード → 1行 "🟢 OK (5h:XXXk tok $X.X / 7d:$XXX)"
        └─ JSON  モード  → 整形済みJSONオブジェクト(全フィールド)
Enter fullscreen mode Exit fullscreen mode

The key point is that each layer fails open independently. If ccusage can't be read, it proceeds to the Python aggregation. If the Python aggregation comes up empty, it exits through fail_open(). Whichever layer breaks, the design guarantees it doesn't go silent.

Reading the fail_open() helper

Lines 15–27 at the top of the script condense the entire design philosophy.

set -u  # -e は外す: fail-open 方針
LOG="$HOME/.claude/logs/cost-log.jsonl"
MODE="${1:-json}"

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

The reason for dropping set -e is stated in a one-line comment: "fail-open 方針" (fail-open policy). That's the declaration of design intent.

fail_open() takes an error-reason string as an argument. In --short mode it prints a single line, ⚫ n/a; in JSON mode it emits a minimal JSON object containing 5h_status:"unknown" and weekly_status:"unknown", then terminates with exit 0. Because it exits zero, both launchd and cron treat it as a normal termination. The dashboard shows ⚫ n/a, and a human instantly understands "some data isn't being collected."

This function gets called in three places.

1. Log file missing (line 29):

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

Day one of setup, or when the log path changes. The existence check lives only here.

2. Python aggregation comes up empty (lines 203–205):

if [ -z "$RESULT" ]; then
  fail_open "python aggregation failed"
fi
Enter fullscreen mode Exit fullscreen mode

For when Python throws an error to stderr and leaves stdout empty. Since 2>/dev/null discards the error output, all bash learns is "aggregation failed."

3. JSON parse failure (line 208):

python3 -c "import sys,json; print(json.load(sys.stdin)['_short'])" 2>/dev/null || fail_open "json parse failed"
Enter fullscreen mode Exit fullscreen mode

For when Python emits malformed JSON. The || falls through to fail_open.

In every case, fail_open guards only the points where "if this trips, downstream output can't be guaranteed." This isn't defensive programming that catches every error — it's a design that explicitly protects the minimal set of chokepoints where a failure means no output at all.

Multi-stage fallback: ccusage → cost-log.jsonl

The data sources are a two-stage setup (lines 34–56).

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('|')
except Exception:
    print('|')
" 2>/dev/null || echo "|")
    CC_OUTPUT_TOK="${EXTRACTED%|*}"
    CC_COST_5H="${EXTRACTED#*|}"
  fi
fi
Enter fullscreen mode Exit fullscreen mode

ccusage blocks --json 2>/dev/null || true — throwing errors into /dev/null and falling back to true keeps the pipeline from stopping. Even in an environment where ccusage doesn't exist, processing continues with CC_OUTPUT_TOK and CC_COST_5H as empty strings.

Inside the inline Python script, try/except Exception swallows all exceptions and prints | (just the separator) on failure. After splitting EXTRACTED, you get CC_OUTPUT_TOK="" and CC_COST_5H="", and the rest of the processing treats it as "no ccusage."

When ccusage is alive, its values take priority over the self-log aggregates as the "official" numbers (lines 126–131).

# 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

On top of that, the source_diff_pct field computes the divergence rate against the self-computed aggregate (lines 135–137) and includes in the JSON how far apart the two data sources are.

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

This is for debugging, but it's also early detection for bugs in the self-log aggregation logic. In fact, when this source_diff_pct exceeded 20%, I discovered a session double-counting bug in cost-log.jsonl.

The two-mode design: --short / --json

The --short mode, meant for dashboard integration, narrows output to a single line. The actual output format is defined at line 196 of the Python heredoc.

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

For example, healthy looks like 🟢 OK (5h:342k tok $1.2 / 7d:$48), and a warning looks like 🟡 burst (5h:823k tok $4.1 / 7d:$1204). The format assumes it's called by launchd every 30 minutes and embedded in the terminal status bar.

The icon decision logic is concentrated in lines 173–181.

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

The actual threshold values live at lines 139–141.

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 a 5-hour block over 800,000 turns it 🟡; over 1,200,000 turns it 🔴. Weekly cost over $3,000 turns it 🟡. If the average session count over the last 3 days exceeds 5, a "focused work" flag is raised. That detection isn't a separate alert — it's folded into the advice string (line 169).

if burst:
    advice_parts.append(f"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中")
Enter fullscreen mode Exit fullscreen mode

JSON mode formats output through python3 -m json.tool (line 210). It's for manual inspection and for piping into other scripts. --short targets launchd's automated invocation, JSON targets a human checking manually — that separation of roles is the essence of the two-mode design.

When --short returns ⚫ n/a, running JSON mode by hand puts the error reason in the advice field.

{"5h_status":"unknown","weekly_status":"unknown","advice":"python aggregation failed"}
Enter fullscreen mode Exit fullscreen mode

Even the debugging path from dashboard to JSON mode is self-contained in those two modes.

Implementation details

The heart of cost calculation: two-pass aggregation and the latest dict

The meatiest part of the script is the Python aggregation block at lines 80–124. It reads the file once and throws it away, then reads it again. Let's start with why it's two-pass.

The spec of cost-log.jsonl is: "for each (session ID, transcript file) pair, cumulative values are progressively overwritten." While Claude Code keeps running within the same session, the running token total up to that moment is appended as a JSONL line every 10 minutes. In other words, if you just sum every line in the file, you add the same cost dozens of times over.

My first naive implementation did exactly that. A session whose real cost was $0.8 ballooned to $12 — one multiple per log line.

The corrected code looks like this (lines 99–111).

# 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

Note that the key is a (session_id, transcript) tuple. If you key on session_id alone, a different transcript file generated when Claude Code restarts gets overwritten as "the same session." That actually wiped out an entire day of cost data once. Keying on the pair of both fields gives you the right granularity: "the latest state of the same work context."

After building this dict, it loops with for (sid, _tr), (t, r) in latest.items() (line 113), deciding whether the timestamp falls within 5h or 7d and aggregating accordingly. The two-pass structure exists because the requirement "use only the latest line" can't be satisfied in one pass. To aggregate while reading in a streaming fashion, you'd first have to scan everything to pin down the final line for each key.

The first loop, still sitting at lines 80–97, is nearly empty.

with open(log_path) as f:
    for line in f:
        try:
            r = json.loads(line)
            t = datetime.datetime.fromisoformat(r["ts"])
        except Exception:
            continue
        sid = r.get("session_id", "")
        ...
        if t >= cutoff_5h:
            pass  # ← ここが空
Enter fullscreen mode Exit fullscreen mode

It literally says pass. That's the part where the comment reads "if the transcript is the same we want to overwrite-aggregate with the latest line → simple summing is fine here." The trace of me hesitating mid-rewrite is still sitting there. In reality, the 5-hour window aggregation is also done from the latest dict in the second pass (lines 116–119). The first pass currently only updates sess_7d_by_day, and even that dict isn't used for the final burst determination (the by_day counter is used instead) — the more you read, the more visible the "refactor stopped halfway" evidence becomes.

This isn't a bug; it's a judgment call that there was no need to break working code. The script runs correctly at 212 lines.

The ccusage-priority merge pattern

The implementation that "composes ccusage data and self-log aggregates with a priority order" is just 6 lines, at 126–131.

# 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

Stashing the original value in own_out_5h is the important part — it's there so that when diff_pct is computed at lines 134–136, there's something to compare the ccusage value against.

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

The source_diff_pct field is only visible in JSON mode. You don't normally think about it, but when a bug creeps into the self-log logic, you notice the instant it diverges from ccusage by 20% or more. I've found two bugs that way.

In environments where ccusage isn't usable (e.g. command -v ccusage fails due to a PATH issue), cc_out and cc_cost both stay None, the priority handling is skipped, and it runs on self-log aggregates alone. Because the "stages" of the two-stage fallback are cleanly separated, you can also confirm which source was used via the ccusage_used field (line 194).

Why Python is invoked via heredoc

The way Python is launched at line 59 is slightly unusual.

RESULT=$(python3 - "$LOG" "${CC_OUTPUT_TOK:-}" "${CC_COST_5H:-}" <<'PY' 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

python3 - is the mode that reads a script from stdin. After that, <<'PY' pipes a heredoc into stdin. Arguments are passed via sys.argv.

Why not split it out into a file? For the simplicity of placement: "everything is contained in one script." When I copy this script to another environment, I don't have to separately verify that the Python file exists. Drop one file into ~/.claude/scripts/ and it works. The launchd plist configuration just points at that single path.

The important detail is that the heredoc delimiter is wrapped in single quotes as <<'PY'. With an unquoted <<PY, bash variable expansion is applied inside the heredoc. The moment {} or $HOME shows up in the Python code, expansion kicks in and you get a syntax error. I didn't know that, and wrote it as <<PY at first.


Where I got stuck

Session duplication made costs look "5x" bigger

When I first implemented naive summing over cost-log.jsonl, 7d_cost_usd came out at 4–6x the actual billed amount. Judging by the symptom alone, it looks like "API costs are exploding."

The first thing I did to verify was check the real billed amount with ccusage daily. It said $48. The script was returning $234.

Next, looking a bit at the contents of cost-log.jsonl, I saw dozens of lines with the same session_id and incrementing cost_usd values. If a session is written out every 10 minutes, you get a run of cumulative values like 0.12, 0.24, 0.37, 0.51.... Adding all of those up recorded a $0.51 session as $1.24.

Even after identifying "summing all lines of cumulative values" as the cause, I hesitated once on the fix strategy. To "take only the final line" you have to scan every line once, and the answer changes depending on whether "final line" means "latest timestamp" or "last in file order." Considering that writes to the file might not always be in chronological order, I settled on an implementation that explicitly compares timestamps (if (prev is None) or (t > prev[0]) at lines 107–111).

After the fix, source_diff_pct read 2.3%. That wasn't an error — it was normal divergence caused by a difference in calculation basis between the self-log aggregation and ccusage (the output-token counting method differs slightly).

The day launchd couldn't find ccusage

The first week after registering the script in a launchd plist, the status bar kept emitting ⚫ n/a every single time. Running ~/.claude/scripts/token-budget-advisor.sh --short by hand returned 🟢 OK. Tracking down the cause took two hours.

The PATH of the shell launchd starts is /usr/bin:/bin:/usr/sbin:/sbin. It's a completely different thing from the $PATH you have in your terminal. When command -v ccusage fails, CC_JSON ends up empty and the ccusage path is skipped. But that wasn't the problem — the problem was that a different script (an older version that depended on ccusage) was written fail-closed and did exit 1 the moment the ccusage command wasn't found.

Since rewriting to this script, it runs off self-log aggregation even when ccusage is outside PATH. If ccusage is found, ccusage_used: true; if not, it stays ccusage_used: false and aggregates from the self-log alone. Neither path produces ⚫ n/a.

As for handling the plist, the root fix is adding nvm's bin path to launchd's EnvironmentKeys. But then the plist needs updating every time the nvm version changes. Keeping a fail-open design that works without ccusage has a lower management cost over the long run.

I forgot to remove set -e in one version, and the dashboard was dead for a week

The predecessor of this script was a different file. When I first wrote it, it started with set -euo pipefail. Because "it looks robust."

One Monday morning, the ccusage API returned a timeout. ccusage blocks --json threw an error to stderr and terminated with exit 1, so bash's CC_JSON=$(ccusage blocks --json) caught the error and the entire script exited 1. set -e "worked" exactly as intended.

As a result, the dashboard status bar went blank. Blank looks like "no problems." That week, I missed the ccusage API anomaly entirely and kept doing heavy work, and the 5-hour block crossed critical twice. It wasn't a cost problem — it was a "I couldn't notice" problem. A dashboard that says nothing is the worst possible warning.

I realized it the following week while chasing down why the status bar had been blank. Looking at launchd's logs (under ~/Library/Logs/), there were exit-1 records at the same time every day. It turned out the ccusage timeouts had been happening intermittently over several days.

This version is the one where I added 2>/dev/null || true, dropped set -e, and left only set -u. || true is the idiom for "silently turn an error into success," but here it carries a clear intent: "even if ccusage fails, don't stop the script." It's not mere defensive programming — it's an explicit statement of the judgment that "errors at this layer should not be propagated to the dashboard."

A source_diff_pct of 28% flew in and made me notice my own bug

Two weeks ago, when I ran JSON mode manually, it showed "source_diff_pct": 28.4. ccusage said the 5h output tokens were 580,000; the self-log said 420,000. A 28% divergence is not within margin of error.

At first I thought "ccusage must be wrong." There's no way a homemade log is more trustworthy than the official tool, but that's what it felt like.

When I actually inspected cost-log.jsonl with jq, I noticed there were a large number of lines with the same session_id under a different transcript path, and both had nearby timestamps. When Claude Code is shut down once and restarted, a new transcript file is generated — and at that point both the final line of the old transcript and the initial lines of the new one entered the latest dict, double-counting the session's cost.

Within key = (r.get("session_id", ""), r.get("transcript", "")), the transcript field sometimes didn't exist in older versions of the cost log. In that case transcript becomes an empty string, and different transcripts get collapsed into the same key (session_id, "") with only the latest line surviving — that's the behavior I thought I had, but in some lines the field name for transcript was "transcript_path". So r.get("transcript", "") was returning an empty string.

The fix is one line. I changed it to r.get("transcript") or r.get("transcript_path", ""). That said, it's a separate fix to this script, and the code shown here still has the old r.get("transcript", ""). Without source_diff_pct, I would have noticed that discrepancy far, far later. The practical value of a design that runs two data sources in parallel and reports the divergence rate only really hit me at that moment.

I confused <<PY and <<'PY' and broke Python

This is a repeat, but since it actually happened I'm recording it.

When I wrote the Python heredoc delimiter as <<PY (unquoted), the {} in by_day = collections.Counter() inside the Python code became a target of bash brace expansion. The error message was syntax error near unexpected token '}', which reads as nothing other than "the Python code is broken."

Per bash's heredoc spec, if the delimiter isn't quoted, variable expansion, command substitution, and backslash processing all happen inside the heredoc. Any ${...} or $(...) syntax in the Python code gets interpreted by bash. The () in collections.Counter() isn't a problem for bash, but there are cases where dict-literal {} is.

Using <<'PY' (delimiter in single quotes) disables all bash expansion inside the heredoc. When the Python code mixes variable expansion, braces, and command substitution, a quoted delimiter is the correct answer. This is basic bash knowledge, but working backwards from the symptom "why did Python break?" took time.

Failure modes

The walkthrough above covers "how it works." From here I'll enumerate "where it breaks." I won't repeat the earlier ones (duplicate aggregation, launchd PATH, leftover set -e, <<PY vs <<'PY'). These are the additional places I actually got stuck beyond those.

  • Mixing timezone-naive and timezone-aware datetimes. Line 69's now = datetime.datetime.now() is a naive object with no timezone. Line 84's t = datetime.datetime.fromisoformat(r["ts"]) becomes aware if the ts field contains +09:00. The moment you compare naive and aware with t >= cutoff_5h, a TypeError flies. Because the entire script discards stderr with 2>/dev/null, the Python traceback goes nowhere, RESULT just comes back empty, and all you get is fail_open "python aggregation failed". The dashboard shows ⚫ n/a, and even running JSON mode manually gives you nothing but python aggregation failed in the advice field. This is the kind of landmine that detonates the moment an external tool integration changes its ts format.

  • isdigit() is for positive integers only. Line 63: cc_out = int(cc_out_str) if cc_out_str.isdigit() else None. str.isdigit() returns True only for positive integer strings. It's False for empty strings, negative numbers, and floats ("1234.5"), so cc_out falls through to None. If ccusage's API response spec changes to return outputTokens as a float, the script won't stop — it will fall back to the self-log — but you won't notice that the value's source has changed. source_diff_pct becomes null, which is the only clue.

  • The first-pass code is effectively empty. Read lines 90–93 as written and you get: if t >= cutoff_5h: pass. The comment says "simple summing is fine," but it was never updated after migrating to the second-pass latest dict. The actual 5h aggregation is handled at lines 116–119. Anyone reading the code gets confused about "why pass?" It's not a bug, it's a trace of a half-finished refactor — but without a comment, it'll cost you an hour of head-scratching.

  • sess_7d_by_day is never used. Line 78 defines sess_7d_by_day = collections.defaultdict(set) and line 96 writes sess_7d_by_day[day].add(sid). But the burst determination (lines 157–159) uses the by_day counter built in the second pass. sess_7d_by_day is never read once before the process ends. At realistic operating scale it's rare for cost-log.jsonl to exceed a few MB, but run it against a large log and that unnecessary set construction consumes memory.

  • 2>/dev/null kills your debugging. Line 59's entire Python invocation is wrapped in 2>/dev/null. When the script terminates normally (exit 0) but no data comes back, the only clue is the error string in the advice field. When debugging, temporarily removing 2>/dev/null and running it lets you see the Python stack trace. You don't need to remove it in production, but if you don't know this standard move for "I can't tell why it's failing," tracking down the cause of ⚫ n/a will cost you hours.

  • There's no argument validation for --short. Line 17: MODE="${1:-json}". With no argument it becomes json. The check is a string comparison, [ "$MODE" = "--short" ], so passing short (no hyphen) or -short (one hyphen) runs it in JSON mode. Mistype the argument in the launchd plist and the dashboard always gets multi-line JSON back, breaking the parse. You get neither ⚫ n/a nor 🟢 — the status bar displays {. Because the symptom looks similar to the launchd PATH problem, diagnosis gets delayed.

  • Burst detection is lenient in a fresh environment. Line 157: recent_days = sorted(by_day.keys())[-3:]. On day one or two of setup there's less than 3 days of data, so max(1, len(recent_days)) returns 2 or 1. With two days of data you get a two-day average; with one day, the judgment is made from a single day. Burst detection errs toward leniency, so it's the safe direction — but it's the cause of "why am I seeing so many 🟡?" in the first week.

  • The _short key is exposed in JSON mode. Line 196's "_short": f"..." remains included in the JSON output. Because python3 -m json.tool formats and prints every field, external tools parsing the JSON get _short mixed in as an unexpected field. The _ prefix is a Python internal-use convention; JSON has no hiding mechanism. There's no functional problem so I've left it, but if you're integrating externally you should result.pop("_short") before handing it off.


Best practices

Here are the decision criteria I extracted from this one script and six months of operation. Not "you should do this," but "here's why the dashboard went silent in a production environment carrying ¥1.2M/month of revenue when I didn't do this."

1. Declare the design philosophy in a one-line comment at the top

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

Dropping set -e is a deliberate choice, not an omission. Without the comment, a later reader — future me, or someone in code review — will put -e back "to harden it." A comment on line 1 of the script becomes the spec document for the design philosophy.

2. Concentrate output-mode branching in one place inside fail_open()

fail_open() references the MODE variable internally and emits one line for --short or minimal JSON for JSON mode. Having fail_open handle it centrally leaves fewer gaps than branching per-mode at each call site. Passing the error reason as an argument — like fail_open "cost-log.jsonl not found" — leaves debugging information in JSON mode's advice field.

3. For external commands, write both the existence check and the runtime-error swallow

if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
Enter fullscreen mode Exit fullscreen mode

command -v alone can't protect against "it exists but timed out at runtime." || true is the explicit expression of the design stance that "errors at this layer are not propagated to the dashboard." Only with both written does fail-open actually hold.

4. Use two data sources and compute the divergence rate

Run the official tool (ccusage) and your own log in parallel, and continuously compute the divergence with source_diff_pct (lines 134–136). When one of them breaks, a divergence of 20%+ tells you. With a single source, you enter the state where "both can be wrong and you'd never know." When source_diff_pct exceeded 28%, I discovered the transcript field key-name mismatch bug.

5. For cumulative JSONL logs, always take only the latest line, keyed on a pair

Key on the (session_id, transcript) tuple and keep only the latest line (lines 100–111). Keying on session_id alone erases the separate transcript after a restart. Summing all lines multi-counts cumulative values. If your cost aggregation reads "5x the real number," suspect this first.

6. Always use <<'PY' for the Python heredoc delimiter

An unquoted <<PY runs bash variable expansion inside the heredoc. If your Python code mixes {}, $HOME, or command substitution, you get a syntax error that looks like nothing but "Python broke." Using <<'PY' (single quotes) disables all expansion inside the heredoc.

7. Assume launchd's PATH problem and design so it works without external commands

The PATH of the shell launchd starts is /usr/bin:/bin:/usr/sbin:/sbin. It doesn't include nvm or Homebrew bin paths. If you design so it runs on self-log aggregation even when ccusage is outside PATH, a PATH problem won't take down the entire dashboard. Adding PATH to the launchd plist is the root fix, but it needs updating every time a tool's version changes. Fail-open design has a lower long-term maintenance cost.

8. Have fail_open terminate with exit 0

With exit 1, launchd records the job as an error. If every automated run every 30 minutes gets treated as an error, launchd's execution log fills with noise and the real errors (the script got deleted, etc.) become invisible. A monitoring script's fail-open depends on exit 0 making launchd treat it as a normal termination.

9. Limit output to four states

Narrow it to four: ⚫ n/a (no data), 🟢 OK, 🟡 burst, 🔴 cap-near (lines 174–181). Add more than that and the decision logic gets complex, which itself becomes a breeding ground for bugs. Four states are the minimum set that satisfies both "a human can read it instantly" and "the logic stays simple."

10. Collect thresholds as constants at the top of the script

THRESH_5H_WARN     = 800_000
THRESH_5H_CRIT     = 1_200_000
THRESH_WEEK_WARN   = 3000
THRESH_SESS_PER_DAY = 5
Enter fullscreen mode Exit fullscreen mode

As at lines 139–142, gather the decision values as named constants in one place. If magic numbers are scattered through the decision logic, you go hunting through every location every time you tune a threshold. If you're going to adjust thresholds monthly as revenue changes, having the change surface in one place is a hard requirement.

11. Pair --short and JSON as a debugging path

When --short returns ⚫ n/a, the next action must be obvious. Running JSON mode manually puts the error reason in the advice field (python aggregation failed, cost-log.jsonl not found, etc.). Build them as a pair, so that the moment the dashboard indicates "something's off," exactly one next command is determined.

12. Standardize timezones on the log-writing side

Mixing naive/aware between datetime.now() and fromisoformat() kills you instantly with a TypeError. If the log writer pins the ts field to a naive ISO format (e.g. %Y-%m-%dT%H:%M:%S), the aggregation side can compare it safely against datetime.now(). If you're mixing externally generated logs with your own, place a single conversion layer at ingest.

13. Make dead variables explicit — comment them or delete them

Variables that get built but never read, like sess_7d_by_day (lines 78 and 96), confuse the humans who read the code later. Delete it if you can; if there's a reason to keep it, write a comment saying "not currently read because of X." The same goes for the first pass's pass block (lines 91–93). One line saying "old code after migrating to the second pass" prevents the confusion.

14. Understand the limits of isdigit() before using it

Before casting a numeric string returned by an external tool's API to an integer, be conscious that isdigit() is for positive integers only. Generically, the try: int(cc_out_str) except (ValueError, TypeError): None form is safer. The current script depends on the assumption that ccusage returns integers, and a spec change silently switches it to the self-log fallback. Conversion logic that depends on external tools lowers maintenance cost when you make the degraded mode explicit before writing it.


Wrapping up

token-budget-advisor.sh is 212 lines. That's not big. But the design decisions condensed inside it are dense.

Dropping set -e isn't "abandoning robustness." It's the choice to break the chain of "when this script stops, the dashboard stops too." The property you need from a monitoring script is not accuracy but never going quiet. That's the one point I wanted to make through this single script.

In an autonomous environment carrying ¥1.2M/month, the cost of a dashboard saying nothing accumulates in a way that's hard to see. When the ccusage API was timing out for a day, the script with set -e alive kept up a "silence indistinguishable from healthy operation." I found out afterward that the 5-hour block had crossed critical twice that week. It's not that the dashboard kept showing 🟢. It just showed nothing at all. Blank and healthy were visually indistinguishable.

Fail-open is not "a design that ignores errors." It's "a design that delivers the existence of an error to a place where a human can see it." ⚫ n/a is not 🟢. It's the minimum output required to keep an abnormal state from looking normal. The script is still running every 30 minutes today. As long as it runs, it doesn't go quiet.


I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in 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)