Three things I had to fix before my status line could tell me what a session cost
Getting real session cost into a custom Antigravity CLI status line took three fixes: how the script reads stdin, how to tell whether your build sends a cost field, and how to keep a usage ledger that does not double-count.
I could read the invoice at the end of the month. What I could not do was answer a simpler question: which piece of work cost the most last week?
As an indie developer moving between several repositories in a single day, almost everything I run is short. A few large design requests, and then hundreds of completions, searches, and quick confirmations. Those short sessions are exactly the ones that never show up in a monthly total in any useful shape.
CLI 1.1.21 added an unrounded session cost to the status line data model. I sat down to wire that into a custom status line, and hit three separate problems before anything useful appeared on screen. Here they are, in the order I met them.
Start by dumping what your own build actually sends
The mechanism is simple. Put a statusLine block in ~/.gemini/antigravity-cli/settings.json, and whenever the agent state changes, the CLI runs your command, pipes a JSON state payload into its stdin, reads a string from stdout, and renders it at the bottom of the prompt panel.
The official status line documentation lists the fields in that payload. As I will get to below, that table is not guaranteed to match the build on your machine. So the first thing worth doing is not reading the table — it is writing the payload to a file, once.
#!/usr/bin/env bash# ~/.gemini/antigravity-cli/statusline.sh (throwaway, for inspection only)# Goal: capture exactly one real payload from the build you are running.DUMP="$HOME/.gemini/antigravity-cli/payload-sample.json"payload="$(timeout 2 cat)"# Only write once. If it is overwritten on every state change,# you lose the ability to diff it against a later version.if [ -n "$payload" ] && [ ! -s "$DUMP" ]; then printf '%s\n' "$payload" > "$DUMP"fiecho "sampling..."
Start the CLI once, open payload-sample.json, and you know where you stand. I skipped this step, went straight from the docs table to writing code, and took the detour described below.
The reason for timeout 2 cat is the next section.
Reading stdin the obvious way can leave your script hanging
The first line I wrote was this:
payload="$(cat)"
In an ordinary shell script that is fine. A status line script is not ordinary: it runs under a wall-clock budget and has to print something and exit inside it. And during auth token refresh or conversation resume, the CLI can hold the stdin pipe open without writing to it and without closing it. cat waits for end-of-input, so in that state it simply does not come back.
I reproduced the condition locally — a parent that holds the pipe and never writes — and measured both spellings from inside the script itself.
How stdin is read
Parent writes and closes
Parent holds the pipe open
Exit code
payload="$(cat)"
read in 4 ms
never returns; killed from outside
124
payload="$(timeout 2 cat)"
read in 4 ms
returns empty after 2,004 ms
0
Exit code 124 means it was killed for running out of time. On screen this shows up as vague symptoms — the status line stops updating, input feels sticky for a moment — which is why it takes a while to suspect the way your script reads its input.
The fix is to guarantee the return, and to have somewhere to fall back to when nothing arrives.
#!/usr/bin/env bash# A status line that always returns inside its budgetCACHE="$HOME/.gemini/antigravity-cli/.statusline-last"payload="$(timeout 2 cat)"if [ -z "$payload" ]; then # Nothing arrived. Print the previous line and exit quietly. # Exiting non-zero here just puts an error on screen every few seconds. cat "$CACHE" 2>/dev/null || echo "agy" exit 0filine="$(printf '%s' "$payload" | python3 -c 'import sys, jsonp = json.load(sys.stdin)cw = p.get("context_window") or {}vcs = p.get("vcs") or {}print("{} | {} | ctx {:.0f}%".format( (p.get("model") or {}).get("display_name", "?"), vcs.get("branch", "-"), cw.get("used_percentage") or 0,))')"printf '%s' "$line" | tee "$CACHE"
I also tried read -r -t 2, which bounds the wait but only captures the first line — and the payload can arrive pretty-printed across several lines. timeout 2 cat handles both.
One more thing that cost me time. If you test with something like ( sleep 20 ) | timeout 8 ./statusline.sh, the script returns in two seconds but your shell waits for the left side of the pipeline, so it feels like twenty. It is easy to conclude your script is slow when your test harness is. Measure from inside the script.
# Time it from inside; an outer `time` measures the whole pipelineS=$(date +%s%N)payload="$(timeout 2 cat)"E=$(date +%s%N)echo "took=$(( (E - S) / 1000000 ))ms" >&2
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can write a status line script that always returns inside its time budget, instead of one that quietly wedges the CLI
✦You can confirm what your own build actually sends, rather than trusting a field table that may lag behind the release notes
✦You can keep a usage ledger that does not double-count, and answer which project your spend is concentrated in
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This was the second problem. The 1.1.21 release notes say an unrounded session cost was added to the status line data model. The "Available JSON fields" table in the documentation lists cwd, session_id, conversation_id, model, context_window, quota, plan_tier and more — with nothing corresponding to cost. The version value in the documented sample payload is also older than the current release.
So the release notes and the reference table are out of sync. Having read the table first, I nearly concluded the field had not shipped. The only way to know is the dump described earlier.
Rather than betting on either answer, I write for both.
printf '%s' "$payload" | python3 -c 'import sys, jsonp = json.load(sys.stdin)# cost may be absent depending on your build.# Descend with get() so a missing key never takes the status line down.cost = (p.get("cost") or {}).get("total_cost_usd")cw = p.get("context_window") or {}usage = cw.get("current_usage") or {}if cost is not None: print("cost ${:.4f}".format(cost))else: # Store the token breakdown instead; rates can be applied later print("in {} / out {} / cached {}".format( usage.get("input_tokens", 0), usage.get("output_tokens", 0), usage.get("cache_read_input_tokens", 0), ))'
There is a side benefit to writing it this way. current_usage carries input_tokens, output_tokens, cache_creation_input_tokens and cache_read_input_tokens. Keep those four and you can recompute history when rates change or when your plan does. Store only a dollar figure and you cannot.
I am deliberately not printing my own rates here. They differ by plan and change over time. Check your own usage and pricing first, then keep the rate table in a single config file. A cost formula scattered across several scripts is a problem you pay for later.
A naive ledger reads about 50 percent high
The third one. The status line runs every time the agent state changes — idle to thinking, thinking to tool_use, and so on. That means it fires many times within a single conversation.
And context_window.total_input_tokens is a running total for that conversation. It grows on every call. Append one row per invocation and sum every row later, and you get this — reproduced locally with one conversation firing three times and a second firing once.
How the ledger is summed
Total tokens
Difference
Sum every row
15,930
+5,300 (about 50% high)
Take the last row per conversation ID
10,630
—
Obvious once stated, but the inflated numbers look perfectly plausible sitting in a file. You are left with a vague sense of spending more than expected, and because the inflation scales with conversation length, comparisons between kinds of work are distorted too.
Keep only the last value per conversation ID.
#!/usr/bin/env bash# The recording half, kept separate from the rendering halfLEDGER="$HOME/.gemini/antigravity-cli/usage.jsonl"printf '%s' "$payload" | python3 -c 'import sys, json, osp = json.load(sys.stdin)cw = p.get("context_window") or {}row = { "conversation_id": p.get("conversation_id"), "model": (p.get("model") or {}).get("id"), "cwd": p.get("cwd"), "in": cw.get("total_input_tokens"), "out": cw.get("total_output_tokens"), "cost": (p.get("cost") or {}).get("total_cost_usd"),}with open(os.environ["LEDGER"], "a") as f: f.write(json.dumps(row) + "\n")' 2>/dev/null || true
And on the reporting side, collapse by conversation ID.
#!/usr/bin/env python3"""Collapse usage.jsonl per conversation and group by working directory.Summing every appended row double-counts the running totals,so the last row per conversation is the only correct choice."""import jsonimport osfrom collections import defaultdictLEDGER = os.path.expanduser("~/.gemini/antigravity-cli/usage.jsonl")last = {}with open(LEDGER) as f: for line in f: try: row = json.loads(line) except json.JSONDecodeError: continue # drop half-written lines cid = row.get("conversation_id") if cid: last[cid] = row # later rows winby_dir = defaultdict(lambda: {"sessions": 0, "tokens": 0})for row in last.values(): key = row.get("cwd") or "(unknown)" by_dir[key]["sessions"] += 1 by_dir[key]["tokens"] += (row.get("in") or 0) + (row.get("out") or 0)for key, v in sorted(by_dir.items(), key=lambda kv: -kv[1]["tokens"]): per = v["tokens"] / v["sessions"] print(f"{key:<40} {v['sessions']:>5} sessions {v['tokens']:>12,} tokens avg {per:>10,.0f}")
Grouping by cwd is the part that matters. It buckets spend per repository, which for anyone running several products in parallel is the view you actually want.
Rounding hides frequency, not unit price
With a ledger in place, is an unrounded figure worth the trouble? Rounding to cents costs you less than a cent per session, which sounds like noise.
It stops being noise when session counts are lopsided. Three workload groups, worked through:
Workload
Actual
Summed after rounding to cents
Share (actual)
Share (rounded)
Design and large refactors ($0.62 × 20 sessions)
$12.40
$12.40
69.1%
77.5%
Small questions and completions ($0.004 × 620 sessions)
$2.48
$0.00
13.8%
0.0%
Routine verification runs ($0.017 × 180 sessions)
$3.06
$3.60
17.1%
22.5%
Any group whose per-session amount falls below the rounding unit sums to $0.00 no matter how often it runs. It is 13.8% of real spend and 0% of what you see. The absolute dollars are small; the distortion of the shares is the real problem, because deciding what to cut is a question about shares.
What rounding hides is frequency, not unit price. Expensive work is unaffected. Cheap, frequent work disappears — and the more you automate, the more of that kind of call you make.
Does the ledger need a lock?
With several repositories open at once, several CLIs append to the same usage.jsonl concurrently. I assumed I would need a lock — interleaved writes, corrupted lines, the usual story.
I measured it first. Six processes appending to one file with O_APPEND, at varying line lengths, counting lines that failed to parse:
Line length
Write method
Lines
Failed to parse
~132 bytes
single os.write
240
0
~8,032 bytes
single os.write
240
0
~8,030 bytes
buffered open(path, "a")
240
0
~20,030 bytes
buffered open(path, "a")
240
0
Not one corrupted line. A write to a descriptor opened with O_APPEND seeks and writes without a gap in between, so no other process lands in the middle of a line. The buffered version behaves the same because the whole string is flushed once when the with block exits.
So in this case I would recommend not adding a lock. A lock adds a failure path, and the status line runs under a tight budget — waiting on a lock is itself a way to hit the timeout described earlier. If the guarantee already holds, doing nothing is the safest option.
The guarantee has conditions, though, and this is where I nearly tripped:
Write each line in one call. Flushing mid-line, or writing the body and the newline separately, opens the gap you were trying to avoid. Build the whole string including \n and hand it over once.
Keep lines small. It is tempting to store the entire payload; you only need conversation ID, cwd, and the token breakdown. Longer lines leave more room for a split on some systems.
Do not put the ledger in a synced folder or on a network share. I keep repositories under a sync folder, so I almost created the ledger there too. Under cloud sync, a partially appended state can become visible to another process. The ledger lives on local disk; only the rollup output leaves the machine.
Once you can no longer honour those three, that is the moment to reach for a lock. This is a local measurement file rather than production state, and "delete it and start over" is an acceptable recovery — that assumption is part of the call.
Split what goes on screen from what goes in a file
After those three fixes I separated rendering from recording. The status line has a tight time budget, and mixing heavy work into it walks you back toward the timeout.
Where
What goes there
Why
Status line (redrawn constantly)
Model, branch, context usage, rough running total
Only information that could make you stop right now is worth watching
JSONL ledger (append only)
Conversation ID, cwd, token breakdown, cost if present
Kept in a form that survives a rate change
Weekly rollup (run by hand)
Per-directory and per-model totals
Weekly is enough; there is no reason to compute this every second
The ledger write ends in || true. Losing the status line because an append failed would be the wrong priority. But swallow errors and you can go weeks without noticing an empty ledger, so my rollup script warns when the ledger has not been touched in two days. Configuration that stops taking effect silently is a recurring shape — I wrote about one version of it in config keys that are ignored on the way through CI — and every silent path is one you have to close yourself.
Exit codes deserve the same care. Wrap a script in one more layer and inner failures stop propagating outward; I covered that pattern in exit codes swallowed by the wrapper.
What the numbers actually told me to stop
I expected the cut list to be obvious once I had data. What I got was slightly more awkward.
The most expensive directory was also where the most valuable work happened. Cutting there is the wrong move. The cheap, high-frequency group — the one rounding had erased — was where cutting cost me almost nothing. It was full of the same confirmation asked repeatedly, and files re-read that could have been read once.
So I stopped ranking by dollars and started ranking by average tokens per session. A large population of very small sessions is usually a habit rather than a task. Sessions with a high average are usually large because they need to be. That is why the rollup prints avg.
Watching the distribution and fixing the habit did more for the quality of the remaining work than watching the total ever did. Two weeks in, I cannot speak to anything seasonal yet. But it beats reading an invoice at the end of the month and feeling vaguely bad about it.
Where to start
Three steps, kept separate:
Drop in the throwaway script and capture payload-sample.json once. Whether your build sends cost is a question only that file answers
Swap in a minimal display-only status line and confirm that timeout 2 cat and the fallback both behave
Then add the recording half, let it run for a week, and run the rollup
Build the recording half first and the timeout symptoms and the accounting mistakes surface together, which makes neither diagnosable. I did not keep that order, which is why the double-counting took me longer than it should have. Reasoning from the documentation table is the same class of detour.
Thank you for reading — I am still reshaping this as I go, and would be glad to keep learning alongside you.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.