AI Coding Costs Are Guesswork Without This: Instrumenting OpenCode And Claude Code

AI Coding Costs Are Guesswork Without This: Instrumenting OpenCode And Claude Code

👁150views

Your coding agent bill is driven by context resend costs, not model price. Focus on ending sessions after one task to avoid the N squared cost scaling, instrument usage before making changes, and disable automatic compaction which destroys cache savings. These steps can reduce costs by over half without changing models.

CloudScale AI SEO: Article Summary
  • 1.
    What it is
    You learn how coding agent costs scale with session length quadratically, why model selection is a smaller lever than context discipline, and practical steps to cut costs significantly without changing models.
  • 2.
    Why it matters
    Because most teams waste money on model routing when the real savings come from session hygiene and caching, both free to implement and more impactful.
  • 3.
    Key takeaway
    End each session deliberately after one task and write a short handoff instead of relying on automatic compaction to save the most money.
~24 min read
🎧 Listen to this article

My inference bill last month was large enough to require an explanation, and my first instinct was the same one most people reach for: I had run one expensive model for everything, so the fix was to route the cheap work to a cheap model and keep the frontier model for the hard parts. That instinct turned out to be wrong, and the companion piece to this one, Cut AI Coding Agent Costs: Fix Context Before You Change Models, walks through why in detail. What it does not walk through is how I actually found that out, because I could not have. Model pricing is a fact you can look up. Whether your bill is a pricing problem is a question you can only answer by looking at your own sessions, and I could not do that from a session total either, since a total tells you what a session cost and says nothing about why.

That is the gap this piece is about closing. Two sessions can spend the same amount for completely different reasons, one because the work genuinely needed it, one because a single turn quietly pulled nine hundred thousand tokens of context into every subsequent turn for a week. From the total alone, they look identical, and so does the honest answer to “is this a model pricing problem,” right up until you can see turn by turn what is actually happening inside a session.

This piece is about getting that view: instrument Claude Code or OpenCode at the turn level, so you can see context growth, cache behaviour, subagent cost and the handful of sessions actually driving your bill, before you touch a single cost lever, and before you trust an instinct about what kind of problem you have. Read this one first; the companion piece is what to actually do once you can see it.

Note: The source code for the solution can be found here: https://github.com/andrewbakercloudscale/ai-agent-cost-usage-panel

1. The Session That Looked Healthy

Here is a real stretch of my own Claude Code usage, about a hundred sessions, with the dollar figures left out since the shape is what matters.

DiagnosticThis article’s thresholdWhat actually showed upVerdict
Cache hit rateUnder 50 percent is the concern98.5 percentFine, caching was never the problem
Input to output ratioOver 20 to 1 is the concern440.8 to 1Miles past the line, this was the problem
Top 10 percent of sessionsOften a third to half of spend63.2 percent of spendWorse than typical, heavily concentrated
Top 5 percent of sessionsNo stated threshold45.6 percent of spend, five sessionsA textbook power law tail
Median session against the worst oneNo stated thresholdRoughly an 80 times spreadThe tail is doing almost all of the damage

Read the first row on its own and the account looks healthy. Ninety eight and a half percent of input tokens hit the cache. By the single metric most people check first, nothing here should have been expensive.

The worst session in that stretch ran for close to six thousand turns over roughly a week, not one sitting. Context climbed from under 40,000 tokens on the first turn to somewhere north of 900,000 tokens by the end. It dipped once, around a third of the way through, almost certainly an automatic compaction, then climbed straight back to the same plateau and stayed there for the rest of the session. Cache hit rate stayed above 98 percent the entire time.

2. Why 98.5 Percent Cache Hit Was Misleading

A perfect cache hit rate tells you the rate was low. It tells you nothing about the volume, and the volume was the entire story in that session.

Caching makes carried context cheaper. It does not make it smaller. Roughly 900,000 tokens were being resent on nearly every one of those six thousand turns, and caching discounted each resend heavily against the standard input rate, but a heavy discount multiplied by six thousand turns is still a large number. This is the mechanism the companion piece argues from first principles, showing up here at a scale that makes it impossible to miss: caching stops you paying full price for a repeated token. Isolation would have stopped you carrying it at all. Those are different things, and the first one is not a substitute for the second.

The more interesting question is not the plateau, it is the reflation right after the dip. Something got pulled back into context immediately after that compaction and never left again, and the honest next step is not to shrug at the climb as one undifferentiated mass but to pull the turn by turn delta around that specific dip and look at exactly what re-entered. A single file or a single tool result re-read on a routine basis is a far more common cause than anything exotic, and it is usually visible within a few turns of looking.

Mapped to the levers covered in the companion piece on cost optimisation, three things would have stopped this outright, in the order they would have mattered:

  1. Session hygiene. A single session should not span a week and thousands of turns. Whatever task boundary existed on day two or three should have ended that session with a handoff instead of continuing.
  2. Isolation. Whatever kept getting re-read at that volume belongs in an explorer subagent, not the main thread. That change alone would have capped the damage regardless of how long the session went on.
  3. Enforcement. This is the clearest possible case for alarm thresholds. A fifteen thousand token delta alarm would have fired on the very first turn of that climb, thousands of turns before the session ended. A hard per session cost ceiling that refuses rather than warns would have forced a restart long before the number got anywhere near what it did.

None of that required guessing. Reading the actual per turn numbers found it in about the time it takes to read a table, which is the entire argument for instrumenting in the first place, and the reason the next three sections exist.

3. The Five Measurements That Exposed It

The table in section 1 was not luck. It came from five numbers that any per turn view will give you, in the order worth checking them:

  1. Cache hit rate. Well under 50 percent and the prompt caching lever in the companion piece is probably worth more to you than everything here combined. Well above it, as in this case, and caching is not where your problem lives.
  2. Input to output ratio. Well above twenty to one and your problem is likely context volume rather than generation. In this dataset it was 440.8 to 1, decisively past the line.
  3. Tokens per session, and the distribution. In my dataset this was a clean power law; check whether yours is too, since the tail is where the fix pays off if it is.
  4. Your ten most expensive sessions, read rather than summarised. In my dataset the top ten percent accounted for 63.2 percent of spend, well above what I expected; read the actual transcripts rather than trusting a total.
  5. Reasoning tokens as a share of output. These are generally billed as output, and output usually costs several times more than input, so check your rate card rather than assume a fixed ratio.

Reading that table in order is exactly how the diagnostics are meant to be used. Diagnostic one says caching is not the issue, so do not go looking there. Diagnostic two says the issue is volume, decisively. Diagnostics three and four say the volume is not spread evenly, it is concentrated in a handful of sessions, so read the worst one rather than trusting its summary. That sequence, not any one number alone, is what finds the actual cause rather than a plausible looking one.

The view you need to compute these looks like this, a per turn breakdown with deltas rather than a session total:

Turn  Model    Input   Δ Context   Cache   Cost
12    Sonnet    42k       +2k        91%    $0.18
13    Sonnet    91k      +49k        88%    $0.39
14    Sonnet    93k       +2k        89%    $0.40

Turn 13 is the decision you investigate. The delta column is the point: absolute numbers tell you the session is big, deltas tell you which turn made it big. Sections 4 and 5 cover how to get exactly that view out of each tool, one you can skip whichever does not match your setup.

4. How To Instrument Claude Code

If you are driving this through Claude Code rather than OpenCode, the equivalent is ccusage, which reads Claude Code’s local session logs directly rather than anything you have to enable. The version below is an installer rather than a single script: it writes two files under ~/.local/bin, a live panel and a launcher, adds a small hook to ~/.zshrc so the panel appears on its own the first time you type a claude command in a new terminal window, and, if you already launch Claude Code through a Finder Service or Automator workflow rather than a shell, patches that script too, since a workflow like that execs claude directly with no interactive shell involved, and the .zshrc hook never fires for it. On macOS with Ghostty the split opens automatically either way; anywhere else, or in any other terminal, the panel itself still runs fine, you just start it by hand with the path it prints at the end.

The launcher also stopped failing silently, which is worth calling out on its own merits. Earlier versions either opened the split or did nothing, with no way to tell which from the terminal. This one logs the reason to ~/.cache/claude-panel-launch.log every time, and it polls for up to two seconds for Ghostty to register as the frontmost window before giving up, because a brand new window does not always report itself as frontmost the instant it opens, and checking once and quitting was quietly losing the race on exactly the windows this is meant to help with. Same principle as the enforcement alarms covered in the companion piece on cost optimisation: a failure you can see is a different thing from a failure that just does not happen. There is a second, subtler version of the same lesson in the latest revision. The split now resizes itself to roughly a third of the window rather than staying at a default fifty fifty, which needed reading the window’s width and doing the keystrokes in a single AppleScript call rather than two separate ones. Splitting it into a measurement call and an action call looked harmless and was not: the frontmost window could change in the gap between them, and when it did, both calls still exited cleanly with nothing in stderr, so the log said ok while nothing had actually happened. That is a worse failure than an honest error, because it looks identical to success. The fix was mechanical once found, do the check and the action in one call so there is no gap to lose the race in, but the failure mode itself is worth remembering next time something automated reports success and you are not sure you believe it.

There is a third version of that same lesson, and it is the most important one: even a single call reporting success is still just a report. The current script no longer trusts the AppleScript’s own return value at all. It snapshots which panel processes exist before the attempt, does the split, waits a second, then snapshots again and checks for an actual new process. Only that counts as success. If nothing new appears, it retries, up to three times, with a permission probe run once up front so a genuinely missing Accessibility grant produces one clear message instead of three confusing failures that all look the same. Every attempt writes to the log under a shared run id, so several terminal windows opening in quick succession do not interleave into an unreadable mess. This is the general shape worth keeping: a system that reports its own success is describing its intention, not confirming an outcome, and the two are only the same thing if you go and check.

Two smaller changes and one genuinely different one round out the current version. The panel now has a purple tier above red, past three times the seven day average rather than two, since a session that is merely twice as expensive as usual and one that is five times as expensive were getting the same colour before. And the per turn table is now guaranteed to print in full regardless of how short your terminal pane is; earlier versions piped the whole panel through a uniform height limit, so on a short split the one section that actually matters could get cut off along with everything else. The table now renders completely first, and only the less essential sections below it, active block, today, the trend, compete for whatever height is left over.

The genuinely different addition is a UserPromptSubmit hook, claude-cost-alert-check.sh, wired into ~/.claude/settings.json. Everything above this point assumes you are looking at a terminal, which is exactly the thing that is not true if you are driving a session over Remote Control from a phone. A hook fires regardless of interface, and its systemMessage shows up inside the chat transcript itself rather than as a local desktop notification, so it is the one part of this whole setup that actually follows you off the laptop. It checks the same red and purple thresholds as the panel, using the same shared multiples so the colours mean the same thing in both places, and separately checks whether the panel launcher’s last attempt for this window failed. It only speaks when something crosses a threshold for the first time or a launch failure is a new one, tracked in a small state file per session, so it will not repeat itself on every prompt once it has already told you.

One correction to the thresholds themselves, and it is the kind of bug that only shows up once you actually use the thing for a while: a multiple of the average is not sufficient on its own. A session that normally costs five cents a turn throwing up a thirteen cent turn is technically more than twice the average, and technically correct, and also not something anyone would ever look at twice. Both scripts now carry an absolute floor alongside the multiple, fifty cents per turn and five dollars per session, below which nothing gets coloured or alerted on regardless of how many multiples of the average it happens to be. The multiple still decides severity once you clear the floor; the floor decides whether the multiple was ever worth asking about. I applied the same floor to the OpenCode panel’s own per turn colouring, including the purple tier it did not have before, so the two tools agree on what red and purple mean rather than one of them using a rule the other does not.

The status line’s own wrapping needed a second pass too. Splitting on every pipe character got the three top level fields onto their own lines, but the cost field itself, session against today against the active block, was still one long string and could still wrap mid word on a narrow pane. The obvious fix is splitting that field again on its internal slashes, and the obvious fix is wrong: the trailing burn rate reads as something like two dollars ten cents per hour, which has its own slash that has nothing to do with the three fields you actually want separated. Splitting on a bare slash cuts the burn rate in half along with everything else. The fix is to split on a slash with a space on either side, which is how the three real fields are separated and never how the burn rate is written, so the rate survives intact while the three fields still land on their own lines. I did not have this exact string to test against, so I built one matching the shape described in the script’s own comment and ran the logic against it directly before trusting it.

One thing worth knowing about before you rely on it: Claude Code has had a documented bug, filed as anthropics/claude-code#17550, where a UserPromptSubmit hook returning hookSpecificOutput JSON shows a generic hook error on the first message of a brand new session specifically, and works normally on every message after that. If you see that on your very first prompt and the alert itself looks otherwise fine, that is almost certainly what happened rather than a broken install. The documented workaround is plain text on stdout instead of JSON, which the same hook event still turns into context Claude can see, just without the same visible banner. Check whether your installed version still does this before deciding it is worth switching.

What it actually shows, beyond the totals from before: a live per turn breakdown of the session you are currently in, turn number, model, input size, the context delta, cache percentage and estimated cost per turn, which is the exact table format from section 3 made real rather than illustrative. It also keeps a seven day rolling average session cost as a baseline, and colours a row yellow past one and a half times that average and red past two times it, so the session from section 1 would have shown red well before turn one hundred rather than only becoming visible at the very end. It is safe to re-run: it overwrites the two scripts with whatever version you paste in and skips the .zshrc block if it is already there.

The per turn cost estimate prices each model itself, since ccusage does not expose per message cost and this reads the raw transcript. Sonnet 5’s launch rate of two dollars per million input and ten per million output was originally introductory through the end of August 2026, with a scheduled increase to three and fifteen; Anthropic cancelled that increase on 10 August 2026 and made the launch rate permanent, so the rate table below reflects that and needs no date based update. Rate cards still move, just not on the schedule this one was originally written against, so check the current table if you are reading this well after publication.

Linked
cat << 'EOF' > claude-panel-setup.sh
#!/usr/bin/env bash
# Installs the Claude Code live usage panel + auto-split launcher.
#
# What this sets up:
#   ~/.local/bin/ccusage-panel.sh        - live stats panel (context %,
#                                           per-turn cost, active block burn
#                                           rate, today/3-day/week/month,
#                                           top sessions)
#   ~/.local/bin/claude-panel-launch.sh  - opens a right-hand Ghostty split
#                                           running the panel above
#   ~/.zshrc (appended, idempotent)      - a preexec hook that runs the
#                                           launcher once per terminal
#                                           window, the first time a
#                                           `claude*` command is typed
#   ~/.local/bin/ghostty-claude-launcher  - patched in place (idempotent)
#                                           if present, so a Finder Service
#                                           / Automator launch path (which
#                                           execs `claude` directly, with
#                                           no interactive shell involved)
#                                           also gets the split panel
#   ~/.local/bin/claude-cost-alert-check.sh - UserPromptSubmit hook script:
#                                           alerts in the chat itself (works
#                                           over Remote Control) when session
#                                           cost hits red/purple vs its 7-day
#                                           average, or the panel launcher
#                                           failed
#   ~/.claude/settings.json (merged via jq, idempotent) - wires the hook
#                                           above into UserPromptSubmit
#
# Requirements: macOS + Ghostty (for the auto-split part — the panel script
# itself works in any terminal), Node.js (for `ccusage`), jq, and
# Accessibility permission granted to Ghostty/Terminal for the System
# Events automation (macOS will prompt the first time if not yet granted).
#
# Safe to re-run: overwrites the two scripts with the latest version and
# skips the .zshrc block if it's already present.
set -uo pipefail

BIN_DIR="$HOME/.local/bin"
mkdir -p "$BIN_DIR"

echo "Installing ccusage-panel.sh ..."
cat > "$BIN_DIR/ccusage-panel.sh" <<'PANEL_EOF'
#!/usr/bin/env bash
# Live Claude Code usage panel — everything ccusage knows: context %, live
# block burn rate + projection, today's breakdown, 3-day trend, week/month
# totals, top sessions today, PLUS a per-turn breakdown of the current
# session (turn/model/context size/context growth/cache hit %/est. cost).
# Run this in a Ghostty split (super+d) to keep it visible while you work.
# Auto-launched by the ccusage split-panel autolaunch hook in ~/.zshrc — see
# ~/.local/bin/claude-panel-launch.sh.
set -uo pipefail
export LC_ALL=C LC_NUMERIC=C

REFRESH="${1:-5}"
TURN_ROWS="${2:-20}"

C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
C_CYAN=$'\033[36m'; C_YELLOW=$'\033[33m'; C_GREEN=$'\033[32m'; C_RED=$'\033[31m'

fmt_num() {
  awk -v n="$1" 'BEGIN{
    n=n+0; neg=(n<0); if(neg) n=-n;
    n=int(n+0.5); s=sprintf("%d",n); out=""; l=length(s);
    for(i=1;i<=l;i++){ out=out substr(s,i,1); if((l-i)%3==0 && i!=l) out=out "," }
    print (neg?"-":"") out;
  }'
}
fmt_money() { printf '$%.2f' "${1:-0}"; }
fmt_hm() { local m=${1:-0}; m=${m%.*}; printf '%dh %02dm' $((m/60)) $((m%60)); }
# A short colored title, not a full-width divider bar — a bar that's drawn
# at $cols but rendered later in a narrower/resized pane just wraps into a
# confusing second row of "=" or "-", which is worse than no rule at all.
header() { local title="$1"; printf '%s%s%s\n' "$C_BOLD$C_CYAN" "$title" "$C_RESET"; }
# Erases to end of line after every printed row before the newline, so a
# frame whose lines are shorter than the previous frame's (e.g. right after
# a pane resize, or just because the numbers got shorter) never leaves
# trailing characters from the old frame ghosting through the new one.
clear_eol() { awk '{ printf "%s\033[K\n", $0 }'; }

while true; do
  printf '\033[H'
  cols=$(tput cols 2>/dev/null || echo 60)
  (( cols < 40 )) && cols=40
  rows=$(tput lines 2>/dev/null || echo 24)
  (( rows < 10 )) && rows=10

  # Everything through the per-turn table is GUARANTEED — printed in full,
  # never truncated, even on a short pane — so "show N turns" always means
  # N turns, not "N turns if there's room after the other sections." Only
  # the sections below it (active block onward, less essential) compete
  # for whatever pane height is left over.
  guaranteed=$( {
  printf '%s%s Claude Code usage — %s (refresh %ss)%s\n' \
    "$C_BOLD" "──" "$(date '+%a %H:%M:%S')" "$REFRESH" "$C_RESET"

  # ---- baselines: average per-session cost over 7 days, total spend over
  # 30 days. Session average needs >=3 real sessions to trust — otherwise a
  # single earlier tiny/huge session would skew it.
  since7=$(date -v-7d +%Y%m%d 2>/dev/null || date -d '7 days ago' +%Y%m%d)
  baseline_json=$(ccusage session --json --since "$since7" --offline 2>/dev/null)
  avg_session_cost=$(jq -r '
    [.session[].totalCost] | map(select(. > 0.05)) |
    if length >= 3 then (add/length) else 0 end
  ' <<<"$baseline_json" 2>/dev/null)
  [ -z "$avg_session_cost" ] && avg_session_cost=0

  since30=$(date -v-29d +%Y%m%d 2>/dev/null || date -d '29 days ago' +%Y%m%d)
  spend30=$(ccusage daily --json --since "$since30" --offline 2>/dev/null | jq -r '.totals.totalCost // 0')
  [ -z "$spend30" ] && spend30=0

  # ---- live status line (current session) ----
  # Needs the REAL session_id and model.id — a placeholder session_id
  # ("live") matches no recorded session (session cost silently comes back
  # $-0.00), and an unset model.id makes ccusage assume an old 200k context
  # window instead of Sonnet 5's actual 1M, so context% reads >100%.
  latest=$(ls -t ~/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
  if [ -n "$latest" ]; then
    IFS=$'\t' read -r sess_id model_id model_label folder_name < <(python3 - "$latest" <<'PYEOF'
import json, os, sys

path = sys.argv[1]
sid = os.path.basename(path).removesuffix(".jsonl")
model = "unknown"
folder = ""
try:
    with open(path) as f:
        for line in f:
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue
            if not folder and d.get("cwd"):
                folder = os.path.basename(d["cwd"])
            if d.get("type") == "assistant":
                m = d.get("message", {}).get("model")
                if m:
                    model = m
except OSError:
    pass

rest = model.removeprefix("claude-")
parts = rest.split("-")
name = parts[0].capitalize()
nums = parts[1:]
if len(nums) >= 2:
    label = f"{name} {nums[0]}.{nums[1]}"
elif len(nums) == 1:
    label = f"{name} {nums[0]}"
else:
    label = name
print(f"{sid}\t{model}\t{label}\t{folder}")
PYEOF
    )
    payload=$(printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","model":{"id":"%s","display_name":"%s"},"workspace":{"current_dir":"%s","project_dir":"%s"},"version":"1.0","output_style":{"name":"default"}}' \
      "$sess_id" "$latest" "$PWD" "$model_id" "$model_label" "$PWD" "$PWD")
    statusline_out=$(echo "$payload" | ccusage statusline -B text 2>/dev/null)
    # One metric per line rather than piping the whole thing through `fold`
    # — a fold-wrapped line breaks mid-word depending on pane width, which
    # reads badly at 1/3 width. ccusage joins fields with " | "; split on
    # that and print each on its own line instead.
    old_ifs="$IFS"
    IFS='|' read -ra statusline_segs <<< "$statusline_out"
    IFS="$old_ifs"
    for i in "${!statusline_segs[@]}"; do
      seg="${statusline_segs[$i]}"
      seg="${seg#"${seg%%[![:space:]]*}"}"
      seg="${seg%"${seg##*[![:space:]]}"}"
      if [ "$i" -eq 1 ]; then
        # The cost segment ("💰 $X session / $Y today / $Z block (...)")
        # is the widest one and the one most likely to wrap mid-word on a
        # narrow pane — give session/today/block their own lines. Split
        # only on " / " (space-slash-space), the separator ccusage uses
        # between those three fields — a bare "/" also turns up inside
        # the trailing burn rate, e.g. "$2.10/hr", and must not be split.
        IFS=$'\n' read -rd '' -a cost_parts < <(printf '%s' "$seg" | sed 's# / #\n#g'; printf '\0')
        for j in "${!cost_parts[@]}"; do
          part="${cost_parts[$j]}"
          if [ "$j" -eq 0 ]; then
            # ccusage already prefixes this field with 💰 itself.
            printf '  %s\n' "$part"
          elif [ "$j" -eq 1 ]; then
            printf '  📅 %s\n' "$part"
          else
            printf '  ⏳ %s\n' "$part"
          fi
        done
      else
        printf '  %s\n' "$seg"
      fi
    done
    # Show just the project folder name (from the transcript's own "cwd"
    # field), not Claude Code's sanitized full-path directory name — the
    # latter is the whole path with slashes turned into dashes and can run
    # well past a narrow 1/3-width split.
    folder_disp="${folder_name:-unknown}"
    folder_maxw=$(( cols - 12 )); (( folder_maxw < 10 )) && folder_maxw=10
    if [ "${#folder_disp}" -gt "$folder_maxw" ]; then
      folder_disp="${folder_disp:0:$((folder_maxw - 3))}..."
    fi
    printf '  📁 Folder: %s\n' "$folder_disp"
    if awk -v a="$avg_session_cost" 'BEGIN{exit !(a>0)}'; then
      printf '  📊 7-day avg session: %s\n' "$(fmt_money "$avg_session_cost")"
    fi
    printf '  💵 30-day spend: %s\n' "$(fmt_money "$spend30")"
  else
    echo "no active Claude Code session found"
  fi
  echo

  # ---- per-turn breakdown of the current session ----
  # ccusage doesn't expose per-message cost (session/daily/blocks only give
  # per-session/day/block totals), so this reads the transcript directly and
  # prices each turn itself. Rates below are Anthropic's published per-model
  # $/1M input & output; cache read/write are the standard multiples of the
  # input rate (0.1x read, 1.25x 5m write, 2x 1h write). Sonnet 5's launch
  # rate of $2/$10 was made permanent on 2026-08-10, cancelling the planned
  # increase to $3/$15; verify this has not changed again before trusting it.
  header "THIS SESSION — PER TURN"
  if [ -n "$latest" ]; then
    python3 - "$latest" "$TURN_ROWS" <<'PYEOF'
import json, sys

path, max_rows = sys.argv[1], int(sys.argv[2])

PRICES = {  # model id -> (input $/1M, output $/1M)
    "claude-sonnet-5":   (2.00, 10.00),
    "claude-opus-5":     (5.00, 25.00),
    "claude-haiku-4-5":  (1.00, 5.00),
    "claude-sonnet-4-6": (3.00, 15.00),
    "claude-opus-4-8":   (5.00, 25.00),
    "claude-opus-4-7":   (5.00, 25.00),
    "claude-opus-4-6":   (5.00, 25.00),
    "claude-fable-5":    (10.00, 50.00),
    "claude-mythos-5":   (10.00, 50.00),
}
DEFAULT_PRICE = (3.00, 15.00)
CACHE_READ_MULT, CACHE_WRITE_5M_MULT, CACHE_WRITE_1H_MULT = 0.1, 1.25, 2.0

def model_label(model_id):
    rest = model_id.removeprefix("claude-")
    parts = rest.split("-")
    name = parts[0].capitalize()
    nums = parts[1:]
    if len(nums) >= 2:
        return f"{name} {nums[0]}.{nums[1]}"
    if len(nums) == 1:
        return f"{name} {nums[0]}"
    return name

def fmt_k(n):
    if abs(n) >= 1000:
        return f"{n/1000:.0f}k"
    return str(n)

turns, seen = [], set()
try:
    with open(path) as f:
        lines = f.readlines()
except OSError:
    lines = []

for line in lines:
    try:
        d = json.loads(line)
    except json.JSONDecodeError:
        continue
    if d.get("type") != "assistant":
        continue
    msg = d.get("message", {})
    usage = msg.get("usage")
    mid = msg.get("id")
    if not usage or not mid or mid in seen:
        continue
    seen.add(mid)

    model = msg.get("model", "unknown")
    in_tok = usage.get("input_tokens", 0)
    out_tok = usage.get("output_tokens", 0)
    cr_tok = usage.get("cache_read_input_tokens", 0)
    cc_tok = usage.get("cache_creation_input_tokens", 0)
    cc = usage.get("cache_creation") or {}
    cw_1h = cc.get("ephemeral_1h_input_tokens", cc_tok if not cc else 0)
    cw_5m = cc.get("ephemeral_5m_input_tokens", 0)

    total_ctx = in_tok + cr_tok + cc_tok
    cache_pct = (cr_tok / total_ctx * 100) if total_ctx else 0.0

    price_in, price_out = PRICES.get(model, DEFAULT_PRICE)
    cost = (
        in_tok * price_in
        + out_tok * price_out
        + cr_tok * price_in * CACHE_READ_MULT
        + cw_1h * price_in * CACHE_WRITE_1H_MULT
        + cw_5m * price_in * CACHE_WRITE_5M_MULT
    ) / 1_000_000

    turns.append((model_label(model), total_ctx, cc_tok, cache_pct, cost))

total_n = len(turns)
shown = turns[-max_rows:]
if not shown:
    print("  (no assistant turns yet)")
else:
    if total_n > len(shown):
        print(f"  (showing last {len(shown)} of {total_n} turns)")
    print(f"  {'Turn':<6}{'Model':<12}{'Input':>8}{'Δ Context':>11}{'Cache':>8}{'Cost':>9}")
    start_idx = total_n - len(shown) + 1
    for i, (label, total_ctx, delta, cache_pct, cost) in enumerate(shown):
        turn_no = start_idx + i
        print(f"  {turn_no:<6}{label:<12}{fmt_k(total_ctx):>8}{'+' + fmt_k(delta):>11}{cache_pct:>7.0f}%{'$' + format(cost, '.2f'):>9}")
    session_cost = sum(t[4] for t in turns)
    print(f"  est. session total: ${session_cost:.2f} (all {total_n} turns in this file)")
PYEOF
  else
    echo "  (no active Claude Code session found)"
  fi
  } )
  printf '%s\n' "$guaranteed" | clear_eol
  used_lines=$(printf '%s\n' "$guaranteed" | wc -l | tr -d ' ')
  remaining=$(( rows - 1 - used_lines ))

  if (( remaining > 0 )); then
  {
  echo

  # ---- active 5h block: burn rate + projection ----
  header "ACTIVE BLOCK"
  block_json=$(ccusage blocks --active --json --offline 2>/dev/null)
  has_block=$(jq -r '.blocks | length // 0' <<<"$block_json" 2>/dev/null)
  if [ "${has_block:-0}" = "1" ]; then
    IFS=$'\t' read -r start end cost tokens cph tpm rem projCost projTokens models <<<"$(jq -r '
      .blocks[0] |
      [
        (.startTime[0:19]+"Z" | fromdateiso8601 | strftime("%H:%M")),
        (.endTime[0:19]+"Z"   | fromdateiso8601 | strftime("%H:%M")),
        .costUSD, .totalTokens, .burnRate.costPerHour, .burnRate.tokensPerMinute,
        .projection.remainingMinutes, .projection.totalCost, .projection.totalTokens,
        (.models | join(", "))
      ] | @tsv
    ' <<<"$block_json")"
    burn_color="$C_GREEN"
    awk -v c="$cph" 'BEGIN{exit !(c+0>3)}' && burn_color="$C_YELLOW"
    awk -v c="$cph" 'BEGIN{exit !(c+0>6)}' && burn_color="$C_RED"
    printf '  window   %s – %s  (%s left)\n' "$start" "$end" "$(fmt_hm "$rem")"
    printf '  spent    %s   %s tok\n' "$(fmt_money "$cost")" "$(fmt_num "$tokens")"
    printf '  burn     %s%s/hr%s   %s tok/min\n' "$burn_color" "$(fmt_money "$cph")" "$C_RESET" "$(fmt_num "$tpm")"
    printf '  proj.    %s total   %s tok\n' "$(fmt_money "$projCost")" "$(fmt_num "$projTokens")"
    printf '  models   %s\n' "$models"
  else
    echo "  (no active block)"
  fi
  echo

  # ---- today: totals + per-model breakdown ----
  header "TODAY"
  daily_json=$(ccusage daily --json --last 1 --offline 2>/dev/null)
  if [ -n "$daily_json" ] && [ "$(jq -r '.daily | length' <<<"$daily_json" 2>/dev/null)" != "0" ]; then
    IFS=$'\t' read -r tCost tTok tIn tOut tCacheC tCacheR <<<"$(jq -r '
      .totals | [.totalCost, .totalTokens, .inputTokens, .outputTokens, .cacheCreationTokens, .cacheReadTokens] | @tsv
    ' <<<"$daily_json")"
    printf '  total    %s   %s tok\n' "$(fmt_money "$tCost")" "$(fmt_num "$tTok")"
    printf '  in/out   %s / %s   cache new/read %s / %s\n' \
      "$(fmt_num "$tIn")" "$(fmt_num "$tOut")" "$(fmt_num "$tCacheC")" "$(fmt_num "$tCacheR")"
    while IFS=$'\t' read -r mname mcost mtok; do
      [ -z "$mname" ] && continue
      printf '    %-24s %8s  %s tok\n' "$mname" "$(fmt_money "$mcost")" "$(fmt_num "$mtok")"
    done < <(jq -r '.daily[0].modelBreakdowns[]? | [.modelName, .cost, (.inputTokens+.outputTokens+.cacheCreationTokens+.cacheReadTokens)] | @tsv' <<<"$daily_json")
  else
    echo "  (no usage yet today)"
  fi
  echo

  # ---- 3-day trend ----
  header "LAST 3 DAYS"
  since3=$(date -v-2d +%Y%m%d 2>/dev/null || date -d '2 days ago' +%Y%m%d)
  trend_json=$(ccusage daily --json --since "$since3" --offline 2>/dev/null)
  if [ -n "$trend_json" ]; then
    barw=$((cols - 22)); (( barw < 10 )) && barw=10
    maxcost=$(jq -r '[.daily[].totalCost] | max // 1' <<<"$trend_json")
    awk -v m="$maxcost" 'BEGIN{if(m<=0) print 1; else print m}' >/dev/null
    while IFS=$'\t' read -r day dcost dtok; do
      [ -z "$day" ] && continue
      n=$(awk -v c="$dcost" -v m="$maxcost" -v w="$barw" 'BEGIN{ if(m<=0) m=1; n=int((c/m)*w+0.5); if(n<0)n=0; print n }')
      bar=$(printf '%*s' "$n" '' | tr ' ' '#')
      printf '  %-5s %-*s %s\n' "${day:5}" "$barw" "$bar" "$(fmt_money "$dcost")"
    done < <(jq -r '.daily[] | [.period, .totalCost, .totalTokens] | @tsv' <<<"$trend_json")
  fi
  echo

  # ---- week / month totals ----
  header "WEEK / MONTH"
  week_cost=$(ccusage weekly --json --last 1 --offline 2>/dev/null | jq -r '.totals.totalCost // 0')
  month_cost=$(ccusage monthly --json --last 1 --offline 2>/dev/null | jq -r '.totals.totalCost // 0')
  printf '  this week   %s\n' "$(fmt_money "$week_cost")"
  printf '  this month  %s\n' "$(fmt_money "$month_cost")"
  echo

  # ---- top sessions today ----
  header "TOP SESSIONS TODAY"
  session_json=$(ccusage session --json --since "$(date +%Y%m%d)" --offline 2>/dev/null)
  if [ -n "$session_json" ] && [ "$(jq -r '.session | length' <<<"$session_json" 2>/dev/null)" != "0" ]; then
    while IFS=$'\t' read -r sid scost stok slast; do
      [ -z "$sid" ] && continue
      lasthm=$(jq -rn --arg t "$slast" '($t[0:19]+"Z") | fromdateiso8601 | strftime("%H:%M")' 2>/dev/null)
      printf '  %-10s %8s  %s tok  last %s\n' "${sid:0:10}" "$(fmt_money "$scost")" "$(fmt_num "$stok")" "$lasthm"
    done < <(jq -r '.session | sort_by(-.totalCost) | .[0:5][] | [.period, .totalCost, .totalTokens, .metadata.lastActivity] | @tsv' <<<"$session_json")
  else
    echo "  (none)"
  fi
  } | head -n "$remaining" | clear_eol
  fi
  printf '\033[0J'

  sleep "$REFRESH"
done
PANEL_EOF
chmod +x "$BIN_DIR/ccusage-panel.sh"

echo "Installing claude-panel-launch.sh ..."
cat > "$BIN_DIR/claude-panel-launch.sh" <<'LAUNCH_EOF'
#!/usr/bin/env bash
# Opens a right-hand Ghostty split running the live ccusage panel, shrinks
# it to ~1/3 of the window width (splits are created 50/50 by default),
# then returns keyboard focus to the left (original) pane. Invoked once
# per terminal window by the ccusage split-panel autolaunch hook in
# ~/.zshrc, or directly by ~/.local/bin/ghostty-claude-launcher. Needs the
# ctrl+shift+h/l resize_split keybinds in ~/.config/ghostty/config
# (installed by claude-panel-setup.sh).
#
# Every invocation writes a run to $LOG, one line per step, prefixed with
# a shared run id so concurrent/rapid invocations (opening several windows
# at once) don't interleave into an unreadable mess. Read it with:
#   tail -50 ~/.cache/claude-panel-launch.log
#
# This retries up to 3 times and, critically, VERIFIES success by checking
# for an actual new ccusage-panel.sh process afterward rather than trusting
# AppleScript's own exit code — an early version logged "ok" while doing
# nothing, because a stale frontmost check or a silent internal early
# "return" inside the AppleScript both exit 0 with no stderr.
set -uo pipefail

LOG="$HOME/.cache/claude-panel-launch.log"
mkdir -p "$(dirname "$LOG")"
RUN_ID="$(date '+%H%M%S')-$$"
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$RUN_ID" "$1" >> "$LOG"; }

panel_pids() { pgrep -f '[b]in/ccusage-panel\.sh' 2>/dev/null | sort; }

log "start: TERM_PROGRAM=${TERM_PROGRAM:-unset} PWD=$PWD"

if [ "${TERM_PROGRAM:-}" != "ghostty" ]; then
  log "abort: not running inside Ghostty (TERM_PROGRAM=${TERM_PROGRAM:-unset})"
  exit 0
fi
if ! command -v osascript >/dev/null 2>&1; then
  log "abort: no osascript on this system (not macOS?)"
  exit 0
fi

ghostty_procs=$(pgrep -x ghostty 2>/dev/null | wc -l | tr -d ' ')
log "context: ${ghostty_procs} ghostty process(es) running"

# A bare permission-probe first — if Accessibility access isn't granted,
# every subsequent step will fail the same way, so say so once clearly
# instead of three confusing retries.
probe=$(osascript -e 'tell application "System Events" to get name of first process' 2>&1)
probe_status=$?
if [ "$probe_status" -ne 0 ]; then
  log "abort: System Events probe failed (exit=$probe_status): $probe"
  log "abort: likely missing Accessibility permission — check System Settings > Privacy & Security > Accessibility for Ghostty"
  exit 0
fi

attempt=0
max_attempts=3
success=0

while [ "$attempt" -lt "$max_attempts" ] && [ "$success" -eq 0 ]; do
  attempt=$((attempt + 1))
  log "attempt $attempt/$max_attempts: begin"

  before_pids=$(panel_pids)

  # Brand-new windows can take a beat to become frontmost at the
  # Accessibility API level — poll instead of checking once and giving up.
  front=""
  polls=0
  for _ in $(seq 1 20); do
    polls=$((polls + 1))
    front=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null)
    [ "$front" = "ghostty" ] && break
    sleep 0.1
  done
  if [ "$front" != "ghostty" ]; then
    log "attempt $attempt: frontmost never became ghostty after $polls polls (last saw '$front') — retrying"
    sleep 0.5
    continue
  fi
  log "attempt $attempt: frontmost confirmed ghostty after $polls poll(s)"

  # Settle delay: frontmost can flip true right as a cold `open -na` launch
  # is still mid-activation-animation, before the window can reliably
  # receive keystrokes.
  sleep 0.3

  # Everything below — the frontmost re-check, the window-width read, the
  # resize math, and every keystroke — happens inside ONE osascript call.
  # Splitting this across two calls previously let frontmost change out
  # from under the second one, and both halves would separately exit 0.
  result=$(osascript <<'APPLESCRIPT' 2>&1
tell application "System Events"
  set frontApp to first application process whose frontmost is true
  if name of frontApp is not "ghostty" then return "skip: frontmost is " & (name of frontApp)
  tell frontApp
    set winSize to size of front window
    set winWidth to item 1 of winSize
    set numPresses to round ((winWidth / 6) / 40)
    delay 0.3
    keystroke "d" using command down
    delay 0.6
    keystroke "~/.local/bin/ccusage-panel.sh"
    key code 36
    delay 0.3
    keystroke "h" using control down
    delay 0.2
    repeat numPresses times
      keystroke "l" using {control down, shift down}
      delay 0.05
    end repeat
  end tell
  return "ok: width=" & winWidth & " presses=" & numPresses
end tell
APPLESCRIPT
  )
  osa_status=$?
  log "attempt $attempt: osascript exit=$osa_status result=$result"

  # Ground truth: did an actual new panel process appear? Don't trust the
  # AppleScript's own report of success — verify it.
  sleep 1
  after_pids=$(panel_pids)
  new_pids=$(comm -13 <(echo "$before_pids") <(echo "$after_pids") 2>/dev/null)
  if [ -n "$new_pids" ]; then
    log "attempt $attempt: VERIFIED — new panel process(es): $(echo "$new_pids" | tr '\n' ' ')"
    success=1
  else
    log "attempt $attempt: FAILED — no new panel process appeared (before=[$(echo "$before_pids" | tr '\n' ' ')] after=[$(echo "$after_pids" | tr '\n' ' ')])"
    sleep 0.5
  fi
done

if [ "$success" -eq 1 ]; then
  log "done: succeeded on attempt $attempt/$max_attempts"
else
  log "done: GAVE UP after $max_attempts attempts — panel did not launch"
  log "done: troubleshooting — confirm ctrl+shift+h/l keybinds exist in ~/.config/ghostty/config, confirm ~/.local/bin/ccusage-panel.sh is executable, try running it manually"
fi

exit 0
LAUNCH_EOF
chmod +x "$BIN_DIR/claude-panel-launch.sh"

ZSHRC="$HOME/.zshrc"
MARKER="# --- ccusage split-panel autolaunch"
if [ -f "$ZSHRC" ] && grep -qF "$MARKER" "$ZSHRC"; then
  echo "~/.zshrc already has the autolaunch hook — leaving it as-is."
else
  echo "Adding the autolaunch hook to ~/.zshrc ..."
  cat >> "$ZSHRC" <<'ZSHRC_EOF'

# --- ccusage split-panel autolaunch (installed by claude-panel-setup.sh) ---
# Fires once per terminal window, the first time a `claude*` command runs:
# opens a right-hand Ghostty split running the live usage panel, then
# returns focus to the left pane. Doesn't touch any existing `claude`
# alias/function — hooks in via preexec instead.
_ccusage_panel_autolaunch() {
  case "$1" in
    claude*) ;;
    *) return ;;
  esac
  [ -n "${CCUSAGE_PANEL_LAUNCHED:-}" ] && return
  export CCUSAGE_PANEL_LAUNCHED=1
  ~/.local/bin/claude-panel-launch.sh &
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec _ccusage_panel_autolaunch
# --- end ccusage split-panel autolaunch ---
ZSHRC_EOF
fi

# If a `ghostty-claude-launcher` script exists (e.g. a Finder Service /
# Automator workflow that runs `open -na Ghostty.app --args -e
# ghostty-claude-launcher <folder>`), patch it too — that path execs
# `claude` directly with no interactive shell involved, so the preexec
# hook above never fires for it. Best-effort: inserts the launcher call
# immediately before the file's last line (its own launch line).
GCL="$BIN_DIR/ghostty-claude-launcher"
GCL_MARKER="# Auto-open the live ccusage stats panel"
if [ -f "$GCL" ] && grep -qF "$GCL_MARKER" "$GCL"; then
  echo "~/.local/bin/ghostty-claude-launcher already patched — leaving it as-is."
elif [ -f "$GCL" ]; then
  echo "Patching ~/.local/bin/ghostty-claude-launcher (Finder Service launch path) ..."
  tmp=$(mktemp)
  gcl_lines=$(wc -l < "$GCL")
  head -n "$((gcl_lines - 1))" "$GCL" > "$tmp"
  cat >> "$tmp" <<'GCL_EOF'

# Auto-open the live ccusage stats panel in a right-hand split. This
# window is always freshly created by the Finder Service (`open -na`), so
# there's no risk of double-launching — runs in the background so it
# doesn't delay Claude Code starting.
[ -x "$HOME/.local/bin/claude-panel-launch.sh" ] && "$HOME/.local/bin/claude-panel-launch.sh" &
GCL_EOF
  tail -n 1 "$GCL" >> "$tmp"
  mv "$tmp" "$GCL"
  chmod +x "$GCL"
else
  echo "No ~/.local/bin/ghostty-claude-launcher found — skipping (not using that Finder Service workflow)."
fi

# The launcher shrinks the new split to ~1/3 width via repeated
# ctrl+shift+l presses — needs these two resize_split keybinds in
# Ghostty's own config (idempotent: skip any already present).
GHOSTTY_CONF="$HOME/.config/ghostty/config"
if [ -f "$GHOSTTY_CONF" ]; then
  added_keybind=0
  if ! grep -qF "keybind = ctrl+shift+h=resize_split:left,40" "$GHOSTTY_CONF"; then
    printf '%s\n' "keybind = ctrl+shift+h=resize_split:left,40" >> "$GHOSTTY_CONF"
    added_keybind=1
  fi
  if ! grep -qF "keybind = ctrl+shift+l=resize_split:right,40" "$GHOSTTY_CONF"; then
    printf '%s\n' "keybind = ctrl+shift+l=resize_split:right,40" >> "$GHOSTTY_CONF"
    added_keybind=1
  fi
  if [ "$added_keybind" -eq 1 ]; then
    echo "Added resize_split keybinds to ~/.config/ghostty/config."
  else
    echo "~/.config/ghostty/config already has the resize_split keybinds — leaving as-is."
  fi
else
  echo "No ~/.config/ghostty/config found — skipping resize keybinds (the split will stay 50/50)."
fi

echo "Installing claude-cost-alert-check.sh ..."
cat > "$BIN_DIR/claude-cost-alert-check.sh" <<'ALERT_EOF'
#!/usr/bin/env bash
# UserPromptSubmit hook. Alerts (via systemMessage, shown in the chat
# transcript itself, which works over Remote Control too since it's part
# of the session, not a local notification) when EITHER:
#   - this session's cost is RED or PURPLE vs the 7-day average session
#     cost (>2x / >3x, same thresholds as ccusage-panel.sh, so "red"
#     means the same thing here and in the panel), or
#   - the ccusage split-panel launcher's last attempt for this window
#     failed (see ~/.local/bin/claude-panel-launch.sh)
# Silent (no output) otherwise, in particular NOT on yellow, per request.
#
# Throttled per session so it fires once per tier increase and once per
# distinct launch failure, not on every single prompt: state is kept in
# ~/.cache/claude-cost-alert-state/<session_id>.json.
set -uo pipefail

STATE_DIR="$HOME/.cache/claude-cost-alert-state"
LAUNCH_LOG="$HOME/.cache/claude-panel-launch.log"
mkdir -p "$STATE_DIR"

YELLOW_MULT=1.5
RED_MULT=2.0
PURPLE_MULT=3.0
MIN_SESSION_ALERT=5.00  # never alert below this, no matter the multiple

latest=$(ls -t ~/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
[ -z "$latest" ] && exit 0
session_id=$(basename "$latest" .jsonl)
state_file="$STATE_DIR/$session_id.json"

since7=$(date -v-7d +%Y%m%d 2>/dev/null || date -d '7 days ago' +%Y%m%d)
avg_session_cost=$(ccusage session --json --since "$since7" --offline 2>/dev/null | jq -r '
  [.session[].totalCost] | map(select(. > 0.05)) |
  if length >= 3 then (add/length) else 0 end
' 2>/dev/null)
[ -z "$avg_session_cost" ] && avg_session_cost="0"

session_cost=$(ccusage session --json -i "$session_id" --offline 2>/dev/null | jq -r '.totalCost // 0')
[ -z "$session_cost" ] && session_cost="0"

tier="normal"
if awk -v v="$session_cost" -v f="$MIN_SESSION_ALERT" 'BEGIN{exit !(v>=f)}'; then
  if awk -v a="$avg_session_cost" -v v="$session_cost" -v m="$PURPLE_MULT" 'BEGIN{exit !(a>0 && v>a*m)}'; then
    tier="purple"
  elif awk -v a="$avg_session_cost" -v v="$session_cost" -v m="$RED_MULT" 'BEGIN{exit !(a>0 && v>a*m)}'; then
    tier="red"
  elif awk -v a="$avg_session_cost" -v v="$session_cost" -v m="$YELLOW_MULT" 'BEGIN{exit !(a>0 && v>a*m)}'; then
    tier="yellow"
  fi
fi

last_launch_line=""
launch_failed=0
if [ -f "$LAUNCH_LOG" ]; then
  last_launch_line=$(grep "done:" "$LAUNCH_LOG" | tail -1)
  [[ "$last_launch_line" == *"GAVE UP"* ]] && launch_failed=1
fi

prev_tier="normal"
prev_launch_line=""
if [ -f "$state_file" ]; then
  prev_tier=$(jq -r '.tier // "normal"' "$state_file" 2>/dev/null)
  prev_launch_line=$(jq -r '.launch_line // ""' "$state_file" 2>/dev/null)
fi

severity() { case "$1" in normal) echo 0 ;; yellow) echo 1 ;; red) echo 2 ;; purple) echo 3 ;; *) echo 0 ;; esac; }

alert_cost=0
if { [ "$tier" = "red" ] || [ "$tier" = "purple" ]; } && [ "$(severity "$tier")" -gt "$(severity "$prev_tier")" ]; then
  alert_cost=1
fi

alert_launch=0
if [ "$launch_failed" -eq 1 ] && [ "$last_launch_line" != "$prev_launch_line" ]; then
  alert_launch=1
fi

# Persist state unconditionally (tracks de-escalation too, so a later
# re-escalation to the same tier alerts again).
python3 - "$state_file" "$tier" "$last_launch_line" <<'PYEOF'
import json, sys
path, tier, launch_line = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, "w") as f:
    json.dump({"tier": tier, "launch_line": launch_line}, f)
PYEOF

if [ "$alert_cost" -eq 0 ] && [ "$alert_launch" -eq 0 ]; then
  exit 0
fi

python3 - "$alert_cost" "$alert_launch" "$tier" "$session_cost" "$avg_session_cost" <<'PYEOF'
import json, sys

alert_cost, alert_launch, tier, session_cost, avg_session_cost = sys.argv[1:6]
parts = []
if alert_launch == "1":
    parts.append("the usage panel failed to launch for this window (see ~/.cache/claude-panel-launch.log)")
if alert_cost == "1":
    parts.append(
        f"session cost is {tier.upper()} (${float(session_cost):.2f} vs a 7-day average of ${float(avg_session_cost):.2f})"
    )
msg = "warning: " + "; ".join(parts)
print(json.dumps({
    "systemMessage": msg,
    "hookSpecificOutput": {
        "hookEventName": "UserPromptSubmit",
        "additionalContext": msg,
    },
}))
PYEOF
ALERT_EOF
chmod +x "$BIN_DIR/claude-cost-alert-check.sh"

CLAUDE_SETTINGS="$HOME/.claude/settings.json"
ALERT_CMD="~/.local/bin/claude-cost-alert-check.sh"
if [ -f "$CLAUDE_SETTINGS" ]; then
  if jq -e --arg cmd "$ALERT_CMD" '
      (.hooks.UserPromptSubmit // []) | any(.hooks[]?.command == $cmd)
    ' "$CLAUDE_SETTINGS" >/dev/null 2>&1; then
    echo "~/.claude/settings.json already has the cost-alert hook — leaving as-is."
  else
    tmp=$(mktemp)
    jq --arg cmd "$ALERT_CMD" '
      .hooks //= {} |
      .hooks.UserPromptSubmit //= [] |
      .hooks.UserPromptSubmit += [{"hooks": [{"type": "command", "command": $cmd, "timeout": 5}]}]
    ' "$CLAUDE_SETTINGS" > "$tmp" && mv "$tmp" "$CLAUDE_SETTINGS"
    echo "Added the cost-alert hook to ~/.claude/settings.json (UserPromptSubmit)."
  fi
else
  echo "No ~/.claude/settings.json found — skipping the cost-alert hook."
fi

echo
echo "Done. Open a NEW terminal window/tab (or 'source ~/.zshrc') and type"
echo "any 'claude...' command — it'll auto-split right and start the panel."
echo "Same goes for the 'Launch Claude Code in Ghostty' Finder Service, if"
echo "you use one (patched above when present)."
echo "Run the panel manually any time with: ~/.local/bin/ccusage-panel.sh"
EOF

That is the exact tool behind the case study in section 1.

5. How To Instrument OpenCode

Start with opencode-tokenscope. It is a plugin, not a separate dashboard: add it, restart OpenCode, then run /tokenscope. It turns OpenCode’s own recorded telemetry into a report covering fresh input against cache reads and writes per step, a retained content inventory broken down by system prompt, user turns, assistant turns and replayable tool output, cache hit rate and estimated savings at public rates, and recursive subagent totals so a delegated call’s cost rolls up into the session that spawned it. That last part matters specifically for checking whether a subagent is actually cheaper once its own cost is counted, which is exactly the isolation lever covered in the companion piece on cost optimisation.

# in your opencode.json plugin array
"plugin": ["@ramtinj95/opencode-tokenscope@latest"]

That alone gets the plugin loaded, but the /tokenscope command itself needs one more file:

mkdir -p ~/.config/opencode/command
cat > ~/.config/opencode/command/tokenscope.md << 'EOF'
---
description: Analyze token usage across the current session with detailed breakdowns by category
---
Call the tokenscope tool directly without delegating to other agents. Then cat the token-usage-output.txt.
EOF

If you want a running dashboard rather than a report you ask for, opencode-token-monitor tracks input, output, reasoning and cache tokens in real time, breaks cost down by agent and by agent crossed with model, keeps a persistent history so you get week over week trend charts, and fires a toast notification when a session crosses a budget threshold you set. Same install pattern, one line in the plugin array.

If the complaint is specifically that the interface itself shows too little, opencode-throughput puts token rate, latency and running cost directly into a sidebar inside the OpenCode TUI rather than a report you have to go and generate. That is the direct fix for wanting more than a summary number on screen while you work.

For a team rather than a single laptop, opencode-plugin-otel exports the same session, token, cost and tool duration signals over OpenTelemetry to whatever you already run, Datadog, Honeycomb, Grafana Cloud, and deliberately mirrors the signal names Claude Code’s own monitoring uses, so a dashboard built for one reads the other. Worth the extra setup once more than one developer needs to see the same numbers; overkill before that.

If you are driving this through OpenCode and want the same live terminal panel rather than a report you ask for, the equivalent is a small installer built directly against OpenCode’s own CLI: opencode session list --format json to find the current session, and opencode export <id> to read it turn by turn. Both are confirmed, documented commands. The exact JSON shape of opencode stats --json is not confirmed as of the version this was written against, so the today and weekly totals in the panel below deliberately show opencode stats‘s own plain output rather than a parsed table; if your installed version supports --json cleanly, that section is the one to upgrade. One genuine advantage over the Claude Code version: OpenCode already computes a cost figure per message, so there is no price table to keep up to date. The launcher below carries the same retry and verify logic as the Claude Code one rather than a simpler version, checking for an actual new panel process after each attempt instead of trusting the AppleScript’s own report, since there is no reason the failure mode described above would be specific to one tool.

Linked
cat << 'EOF' > opencode-panel-setup.sh
#!/usr/bin/env bash
# Installs the OpenCode live usage panel + auto-split launcher.
#
# What this sets up:
#   ~/.local/bin/opencode-panel.sh        - live stats panel (per-turn
#                                            breakdown of the current
#                                            session, 7-day baseline
#                                            flagging, today/week/month
#                                            via `opencode stats`)
#   ~/.local/bin/opencode-panel-launch.sh - opens a right-hand Ghostty
#                                            split running the panel above
#   ~/.zshrc (appended, idempotent)       - a preexec hook that runs the
#                                            launcher once per terminal
#                                            window, the first time an
#                                            `opencode*` command is typed
#
# Requirements: macOS + Ghostty (for the auto-split part — the panel
# script itself works in any terminal), the `opencode` CLI on your PATH,
# and jq. Accessibility permission for Ghostty/Terminal is needed for the
# System Events automation (macOS will prompt the first time).
#
# What this does NOT try to do: parse `opencode stats --json`. As of the
# version this was written against, session list JSON is confirmed
# (`opencode session list --format json`) and session export JSON is
# confirmed (`opencode export <id>`), but the exact JSON shape of
# `opencode stats --json` is not, so the today/week/month section below
# shells out to plain `opencode stats` and shows its own output rather
# than guessing at field names. If your installed version supports
# `--json` on stats cleanly, that section is the one to upgrade.
#
# Safe to re-run: overwrites the two scripts with the latest version and
# skips the .zshrc block if it's already present.
set -uo pipefail

BIN_DIR="$HOME/.local/bin"
mkdir -p "$BIN_DIR"

echo "Installing opencode-panel.sh ..."
cat > "$BIN_DIR/opencode-panel.sh" <<'PANEL_EOF'
#!/usr/bin/env bash
# Live OpenCode usage panel: per-turn breakdown of the current session
# (turn/model/context/Δ cache write/cache hit %/cost), a 7-day average
# session cost used to flag expensive turns, and today/week/month via
# `opencode stats` in its own native format (see the note in the
# installer about why that part isn't parsed as JSON).
set -uo pipefail
export LC_ALL=C LC_NUMERIC=C

REFRESH="${1:-5}"
TURN_ROWS="${2:-20}"

C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'
C_CYAN=$'\033[36m'; C_YELLOW=$'\033[33m'; C_GREEN=$'\033[32m'; C_RED=$'\033[31m'

# A short colored title, not a full-width divider bar — a bar drawn at
# $cols but rendered later in a narrower/resized pane just wraps into a
# confusing second row of "=" or "-", which is worse than no rule at all.
header() { local title="$1"; printf '%s%s%s\n' "$C_BOLD$C_CYAN" "$title" "$C_RESET"; }
# Erases to end of line after every printed row before the newline, so a
# frame whose lines are shorter than the previous frame's (e.g. right after
# a pane resize) never leaves trailing characters from the old frame
# ghosting through the new one — same fix as ccusage-panel.sh.
clear_eol() { awk '{ printf "%s\033[K\n", $0 }'; }

# jq expression tried against two plausible `session list --format json`
# shapes (flat time_created, or nested time.created / sessionID), sorted
# newest first. If neither field exists on your version, this falls back
# to whatever order the CLI already returns.
FIND_LATEST='sort_by(.time_created // .time.created // .timeCreated // .created // 0) | reverse | .[0] | (.id // .sessionID // .session_id // "")'
BASELINE_AVG='
  [ .[] | (.cost // .totalCost // .data.cost // null) ] | map(select(. != null and . > 0)) |
  if length >= 3 then (add/length) else 0 end
'

while true; do
  printf '\033[H'
  cols=$(tput cols 2>/dev/null || echo 60)
  (( cols < 40 )) && cols=40
  rows=$(tput lines 2>/dev/null || echo 24)
  (( rows < 10 )) && rows=10

  {
  printf '%s%s OpenCode usage — %s %s(refresh %ss)%s\n' \
    "$C_BOLD" "──" "$(date '+%a %H:%M:%S')" "$C_DIM" "$REFRESH" "$C_RESET"

  if ! command -v opencode >/dev/null 2>&1; then
    echo "opencode CLI not found on PATH."
  else
    list_json=$(opencode session list --format json 2>/dev/null)
    session_id=""
    if [ -n "$list_json" ]; then
      session_id=$(jq -r "$FIND_LATEST" <<<"$list_json" 2>/dev/null)
    fi

    # ---- 7-day baseline, from the same list (no per-session export) ----
    since7_ms=$(( $(date -v-7d +%s 2>/dev/null || date -d '7 days ago' +%s) * 1000 ))
    avg_session_cost=0
    if [ -n "$list_json" ]; then
      recent_json=$(jq -c --argjson since "$since7_ms" \
        '[ .[] | select((.time_created // .time.created // .timeCreated // .created // 0) >= $since) ]' \
        <<<"$list_json" 2>/dev/null)
      [ -n "$recent_json" ] && avg_session_cost=$(jq -r "$BASELINE_AVG" <<<"$recent_json" 2>/dev/null)
      [ -z "$avg_session_cost" ] && avg_session_cost=0
    fi

    # session_id can be a long opaque id; clip it to fit the pane so it
    # can't wrap and shift the row-count math for `head -n` below.
    sess_disp="${session_id:-none found}"
    sess_maxw=$(( cols - 10 )); (( sess_maxw < 10 )) && sess_maxw=10
    if [ "${#sess_disp}" -gt "$sess_maxw" ]; then
      sess_disp="${sess_disp:0:$((sess_maxw - 3))}..."
    fi
    echo "session: $sess_disp"
    if awk -v a="$avg_session_cost" 'BEGIN{exit !(a>0)}'; then
      printf '%s  7-day avg session: $%.2f%s\n' "$C_DIM" "$avg_session_cost" "$C_RESET"
    fi
    echo

    header "THIS SESSION — PER TURN"
    if [ -n "$session_id" ]; then
      export_json=$(opencode export "$session_id" 2>/dev/null)
      if [ -n "$export_json" ]; then
        export_tmp=$(mktemp)
        printf '%s\n' "$export_json" > "$export_tmp"
        python3 - "$export_tmp" "$TURN_ROWS" <<'PYEOF'
import json, sys

path, max_rows = sys.argv[1], int(sys.argv[2])

def fmt_k(n):
    if abs(n) >= 1000:
        return f"{n/1000:.0f}k"
    return str(n)

turns, seen = [], set()
try:
    with open(path) as f:
        lines = f.readlines()
except OSError:
    lines = []

for line in lines:
    line = line.strip()
    if not line:
        continue
    try:
        d = json.loads(line)
    except json.JSONDecodeError:
        continue
    if d.get("type") != "message":
        continue
    data = d.get("data", {})
    if data.get("role") != "assistant":
        continue
    mid = d.get("id")
    if not mid or mid in seen:
        continue
    seen.add(mid)

    tokens = data.get("tokens", {}) or {}
    in_tok = tokens.get("input", 0)
    cache = tokens.get("cache", {}) or {}
    cache_read = cache.get("read", 0)
    cache_write = cache.get("write", 0)
    cost = data.get("cost", 0) or 0

    total_ctx = in_tok + cache_read + cache_write
    cache_pct = (cache_read / total_ctx * 100) if total_ctx else 0.0

    provider = data.get("providerID", "?")
    model = data.get("modelID", "unknown")
    label = f"{provider}/{model}"[:16]

    # Δ is new cache writes this turn, matching the ccusage panel's
    # convention: it's exactly the tokens that weren't already cached,
    # i.e. what a context spike looks like.
    turns.append((label, total_ctx, cache_write, cache_pct, cost))

total_n = len(turns)
shown = turns[-max_rows:]
if not shown:
    print("  (no assistant turns yet)")
else:
    if total_n > len(shown):
        print(f"  (showing last {len(shown)} of {total_n} turns)")
    print(f"  {'Turn':<6}{'Model':<17}{'Ctx':>7}{'Δ':>8}{'Cache':>7}{'Cost':>8}")
    start_idx = total_n - len(shown) + 1
    for i, (label, total_ctx, delta, cache_pct, cost) in enumerate(shown):
        turn_no = start_idx + i
        print(f"  {turn_no:<6}{label:<17}{fmt_k(total_ctx):>7}"
              f"{'+' + fmt_k(delta):>8}{cache_pct:>6.0f}%{'$' + format(cost, '.2f'):>8}")
    session_cost = sum(t[4] for t in turns)
    print(f"  session total so far: ${session_cost:.2f} ({total_n} assistant turns)")
PYEOF
        rm -f "$export_tmp"
      else
        echo "  (couldn't export session $session_id — 'opencode export' may need a newer CLI version)"
      fi
    else
      echo "  (no OpenCode session found — run 'opencode session list --format json' to check)"
    fi
    echo

    # ---- today / week / month: shown as opencode's own output, not
    # parsed, per the note at the top of the installer ----
    header "TODAY (opencode stats --days 1)"
    opencode stats --days 1 2>/dev/null | head -n 6 || echo "  (opencode stats not available)"
    echo
    header "LAST 7 DAYS (opencode stats --days 7)"
    opencode stats --days 7 2>/dev/null | head -n 6 || echo "  (opencode stats not available)"
  fi
  } | head -n "$((rows - 1))" | clear_eol
  printf '\033[0J'

  sleep "$REFRESH"
done
PANEL_EOF
chmod +x "$BIN_DIR/opencode-panel.sh"

echo "Installing opencode-panel-launch.sh ..."
cat > "$BIN_DIR/opencode-panel-launch.sh" <<'LAUNCH_EOF'
#!/usr/bin/env bash
# Opens a right-hand Ghostty split running the live OpenCode panel, shrinks
# it to ~1/3 of the window width, then returns keyboard focus to the left
# (original) pane. Invoked once per terminal window by the opencode
# split-panel autolaunch hook in ~/.zshrc. Needs the ctrl+shift+h/l
# resize_split keybinds in ~/.config/ghostty/config (installed by
# opencode-panel-setup.sh). Same mechanism as claude-panel-launch.sh, kept
# as a separate script and a separate log so installing both doesn't have
# one clobber the other's diagnostics.
#
# Every invocation writes a run to $LOG, one line per step, prefixed with
# a shared run id so concurrent/rapid invocations don't interleave into an
# unreadable mess. Read it with:
#   tail -50 ~/.cache/opencode-panel-launch.log
#
# This retries up to 3 times and, critically, VERIFIES success by checking
# for an actual new opencode-panel.sh process afterward rather than
# trusting AppleScript's own exit code — a stale frontmost check or a
# silent internal early "return" inside the AppleScript both exit 0 with
# no stderr, so a report of success is not the same thing as success.
set -uo pipefail

LOG="$HOME/.cache/opencode-panel-launch.log"
mkdir -p "$(dirname "$LOG")"
RUN_ID="$(date '+%H%M%S')-$$"
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$RUN_ID" "$1" >> "$LOG"; }

panel_pids() { pgrep -f '[b]in/opencode-panel\.sh' 2>/dev/null | sort; }

log "start: TERM_PROGRAM=${TERM_PROGRAM:-unset} PWD=$PWD"

if [ "${TERM_PROGRAM:-}" != "ghostty" ]; then
  log "abort: not running inside Ghostty (TERM_PROGRAM=${TERM_PROGRAM:-unset})"
  exit 0
fi
if ! command -v osascript >/dev/null 2>&1; then
  log "abort: no osascript on this system (not macOS?)"
  exit 0
fi

ghostty_procs=$(pgrep -x ghostty 2>/dev/null | wc -l | tr -d ' ')
log "context: ${ghostty_procs} ghostty process(es) running"

# A bare permission-probe first — if Accessibility access isn't granted,
# every subsequent step will fail the same way, so say so once clearly
# instead of three confusing retries.
probe=$(osascript -e 'tell application "System Events" to get name of first process' 2>&1)
probe_status=$?
if [ "$probe_status" -ne 0 ]; then
  log "abort: System Events probe failed (exit=$probe_status): $probe"
  log "abort: likely missing Accessibility permission — check System Settings > Privacy & Security > Accessibility for Ghostty"
  exit 0
fi

attempt=0
max_attempts=3
success=0

while [ "$attempt" -lt "$max_attempts" ] && [ "$success" -eq 0 ]; do
  attempt=$((attempt + 1))
  log "attempt $attempt/$max_attempts: begin"

  before_pids=$(panel_pids)

  # Brand-new windows can take a beat to become frontmost at the
  # Accessibility API level — poll instead of checking once and giving up.
  front=""
  polls=0
  for _ in $(seq 1 20); do
    polls=$((polls + 1))
    front=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null)
    [ "$front" = "ghostty" ] && break
    sleep 0.1
  done
  if [ "$front" != "ghostty" ]; then
    log "attempt $attempt: frontmost never became ghostty after $polls polls (last saw '$front') — retrying"
    sleep 0.5
    continue
  fi
  log "attempt $attempt: frontmost confirmed ghostty after $polls poll(s)"

  # Settle delay: frontmost can flip true right as a cold `open -na` launch
  # is still mid-activation-animation, before the window can reliably
  # receive keystrokes.
  sleep 0.3

  # Everything below — the frontmost re-check, the window-width read, the
  # resize math, and every keystroke — happens inside ONE osascript call,
  # for the same reason as the Claude Code version: splitting it across
  # two calls lets frontmost change out from under the second one, and
  # both halves separately exit 0.
  result=$(osascript <<'APPLESCRIPT' 2>&1
tell application "System Events"
  set frontApp to first application process whose frontmost is true
  if name of frontApp is not "ghostty" then return "skip: frontmost is " & (name of frontApp)
  tell frontApp
    set winSize to size of front window
    set winWidth to item 1 of winSize
    set numPresses to round ((winWidth / 6) / 40)
    delay 0.3
    keystroke "d" using command down
    delay 0.6
    keystroke "~/.local/bin/opencode-panel.sh"
    key code 36
    delay 0.3
    keystroke "h" using control down
    delay 0.2
    repeat numPresses times
      keystroke "l" using {control down, shift down}
      delay 0.05
    end repeat
  end tell
  return "ok: width=" & winWidth & " presses=" & numPresses
end tell
APPLESCRIPT
  )
  osa_status=$?
  log "attempt $attempt: osascript exit=$osa_status result=$result"

  # Ground truth: did an actual new panel process appear? Don't trust the
  # AppleScript's own report of success — verify it.
  sleep 1
  after_pids=$(panel_pids)
  new_pids=$(comm -13 <(echo "$before_pids") <(echo "$after_pids") 2>/dev/null)
  if [ -n "$new_pids" ]; then
    log "attempt $attempt: VERIFIED — new panel process(es): $(echo "$new_pids" | tr '\n' ' ')"
    success=1
  else
    log "attempt $attempt: FAILED — no new panel process appeared (before=[$(echo "$before_pids" | tr '\n' ' ')] after=[$(echo "$after_pids" | tr '\n' ' ')])"
    sleep 0.5
  fi
done

if [ "$success" -eq 1 ]; then
  log "done: succeeded on attempt $attempt/$max_attempts"
else
  log "done: GAVE UP after $max_attempts attempts — panel did not launch"
  log "done: troubleshooting — confirm ctrl+shift+h/l keybinds exist in ~/.config/ghostty/config, confirm ~/.local/bin/opencode-panel.sh is executable, try running it manually"
fi

exit 0
LAUNCH_EOF
chmod +x "$BIN_DIR/opencode-panel-launch.sh"

ZSHRC="$HOME/.zshrc"
MARKER="# --- opencode split-panel autolaunch"
if [ -f "$ZSHRC" ] && grep -qF "$MARKER" "$ZSHRC"; then
  echo "~/.zshrc already has the OpenCode autolaunch hook — leaving it as-is."
else
  echo "Adding the OpenCode autolaunch hook to ~/.zshrc ..."
  cat >> "$ZSHRC" <<'ZSHRC_EOF'

# --- opencode split-panel autolaunch (installed by opencode-panel-setup.sh) ---
_opencode_panel_autolaunch() {
  case "$1" in
    opencode*) ;;
    *) return ;;
  esac
  [ -n "${OPENCODE_PANEL_LAUNCHED:-}" ] && return
  export OPENCODE_PANEL_LAUNCHED=1
  ~/.local/bin/opencode-panel-launch.sh &
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec _opencode_panel_autolaunch
# --- end opencode split-panel autolaunch ---
ZSHRC_EOF
fi

GHOSTTY_CONF="$HOME/.config/ghostty/config"
if [ -f "$GHOSTTY_CONF" ]; then
  added_keybind=0
  if ! grep -qF "keybind = ctrl+shift+h=resize_split:left,40" "$GHOSTTY_CONF"; then
    printf '%s\n' "keybind = ctrl+shift+h=resize_split:left,40" >> "$GHOSTTY_CONF"
    added_keybind=1
  fi
  if ! grep -qF "keybind = ctrl+shift+l=resize_split:right,40" "$GHOSTTY_CONF"; then
    printf '%s\n' "keybind = ctrl+shift+l=resize_split:right,40" >> "$GHOSTTY_CONF"
    added_keybind=1
  fi
  if [ "$added_keybind" -eq 1 ]; then
    echo "Added resize_split keybinds to ~/.config/ghostty/config."
  else
    echo "~/.config/ghostty/config already has the resize_split keybinds — leaving as-is."
  fi
else
  echo "No ~/.config/ghostty/config found — skipping resize keybinds (the split will stay 50/50)."
fi

echo
echo "Done. Open a NEW terminal window/tab (or 'source ~/.zshrc') and type"
echo "any 'opencode...' command — it'll auto-split right and start the panel."
echo "Run the panel manually any time with: ~/.local/bin/opencode-panel.sh"
EOF

One thing worth doing before you trust the aggregate section: run opencode stats --json | head yourself once. If it comes back as clean JSON, that section is worth upgrading to match the tighter formatting the Claude Code panel gets from ccusage.

6. What To Measure Every Week

Four numbers, weekly:

  1. Cost per accepted unit of work. Everything included, retries and escalations and overhead, divided by units that passed acceptance. The only number that tells you whether any of this is working.
  2. Cache hit rate, watched like an error rate.
  3. First pass acceptance rate per role. Above roughly 85 percent, you are probably being too conservative and should push more work down a tier. Below roughly 60 percent, inspect the briefs before assuming the answer is a stronger model, since a vague brief and a weak model produce the same symptom.
  4. Top ten sessions by cost, read weekly, not summarised. In every session I have actually gone and read, the cause turned out to be visible in the transcript within a couple of minutes; that is not a guarantee, just what has held so far.

7. Local Telemetry, Provider Billing, And Organisation Wide Analytics

Local telemetry, the per turn view in sections 4 and 5, tells you why a cost happened: which turn, which file, which subagent. It does not tell you what was actually billed. Those are different questions with different authoritative sources, and conflating them is how a plausible looking estimate quietly drifts from the real invoice.

If your Claude traffic is billed directly by Anthropic, the Usage and Cost Admin API is the authoritative source for money, and it draws exactly the line above: spend is a fact it reports precisely, waste is a judgement it cannot make, because whether a call was necessary depends on your workflow, not on anything visible from the provider’s side.

One number falls out of it almost for free. The Usage API separates uncached from cached input tokens per model per day, and crossed against your rate card that gives you a cache waste ceiling in dollars, the organisation wide version of the cache hit rate diagnostic from section 3. In one real case, four hundred million uncached tokens that a well behaved cache should have caught, on Sonnet 5’s confirmed two dollars per million input rate, came to roughly eight hundred dollars in a single week, from one prompt assembly bug. Scale that to a heavier model or a larger fleet and the same mechanism produces a much bigger number; the point is the mechanism, so use your own rate card rather than this one.

Everything past that needs a log of your own, for the same reason a billing report cannot see it: retries, escalations from a cheap tier to an expensive one, truncated responses needing a follow up call, and sessions abandoned before completion all look, in the aggregate, exactly like ordinary successful traffic. None of that is visible on any provider’s bill. It is only visible in whatever your own orchestration code already knows and never wrote down. Once you log which calls were retries, which were escalations, and which sessions ended accepted or not, a true waste rate is direct: sum the cost of everything that was not necessary, divide by the total cost for the same period taken from the Cost API rather than your own log’s sum, which is the same idea as cost per accepted unit of work from section 6, now expressed as a single tracked percentage.

That gives you two levels: local telemetry for causality, provider billing for financial truth. There is a third worth knowing about if you are answering to anyone above individual developer level. Anthropic’s Claude Code Analytics API returns one record per user per day, sessions, lines of code added and removed, commits and pull requests created, tool accept and reject counts, and token usage and estimated cost broken down by model, through an Admin API key with data landing within about an hour. It is aggregated rather than the per turn forensic detail this piece has been arguing for, which is the point rather than a limitation: it answers a different question, which developer’s usage looks unusual, rather than which decision inside a session caused it. The three levels together are a reasonable shape for an organisation rather than an individual: local per turn instrumentation for the developer actually debugging a session, the Analytics API or an OpenTelemetry export for engineering leadership watching trends across a team, and the Usage and Cost API for finance reconciling against the actual bill. None of the three substitutes for either of the others.

8. Why This Comes Before Any Fix

Every lever in the companion piece, ending sessions deliberately, keeping a stable cache prefix, delegating reads to an isolated subagent, all of it rests on being able to see, in your own account, which turn or which session actually did the damage. Without that, cost optimisation is a set of plausible sounding heuristics applied uniformly, and some of them will be wrong for your specific workload.

The case study in sections 1 and 2 is the proof this is not academic. A 98.5 percent cache hit rate looked completely healthy by the most common single metric anyone checks, and it was masking a session that resent nearly a million tokens of context on almost six thousand turns. No amount of reasoning about caching in the abstract would have caught that. Reading the actual per turn numbers did, in about the time it took to read a table.

Install the panel that matches your tool, run the five diagnostics, and read your own top ten sessions before you touch a single lever. The instrumentation is not preparation for the real work. Reading your own numbers accurately is the real work, and everything else follows from what they tell you.


9. Sources

Versions and behaviour move over time. Verify before relying on any of this in production.

  1. opencode-tokenscope, per turn and cache breakdown plugin for OpenCode: https://github.com/ramtinJ95/opencode-tokenscope
  2. opencode-token-monitor, real time dashboard, budgets and trend charts: https://github.com/Ainsley0917/opencode-token-monitor
  3. OpenCode Throughput, live TUI sidebar for latency, throughput and cost: https://github.com/Howardzhangdqs/opencode-throughput
  4. opencode-plugin-otel, OpenTelemetry export mirroring Claude Code’s own monitoring signals: https://github.com/DEVtheOPS/opencode-plugin-otel
  5. OpenCode CLI reference, including session list, export and stats: https://opencode.ai/docs/cli/
  6. ccusage, the CLI this piece’s Claude Code panel is built on top of: https://github.com/ryoppippi/ccusage
  7. Anthropic Usage and Cost Admin API, endpoints, grouping, filtering and time granularity: https://platform.claude.com/docs/en/manage-claude/usage-cost-api
  8. Anthropic Claude Code Analytics API, per user daily productivity and cost metrics: https://platform.claude.com/docs/en/manage-claude/claude-code-analytics-api
  9. Anthropic’s official pricing page, current rates for all models: https://platform.claude.com/docs/en/about-claude/pricing
  10. Anthropic’s announcement making Sonnet 5’s introductory pricing permanent, 10 August 2026: https://x.com/claudeai/status/2086891169217122586