月末に届く請求の数字は分かるのに、「先週いちばん高くついた作業は何でしたか」と聞かれると答えられませんでした。個人開発で複数のリポジトリを行き来していると、一日に走るセッションは短いものばかりが積み上がります。大きな設計依頼は数えるほどで、あとは補完と検索と小さな確認です。
CLI 1.1.21 で、ステータスラインのデータモデルに丸めなしのセッションコストが入りました。これで手元に貯められる、と思って自作のステータスラインを書き始めたのですが、実際に動かすまでに3か所でつまずきました。順に書いていきます。
まず、自分の CLI が何を渡してくるのかを1回だけ書き出す
ステータスラインの仕組みは単純です。~/.gemini/antigravity-cli/settings.json に statusLine ブロックを置くと、エージェントの状態が変わるたびに CLI がそのコマンドを実行し、状態の JSON を標準入力へ流し込み、標準出力に返ってきた文字列を画面下部に描画します。
{
"statusLine": {
"type": "command",
"command": "~/.gemini/antigravity-cli/statusline.sh"
}
}
公式のステータスライン設定ドキュメントには、渡ってくるフィールドの一覧表が載っています。ただし後で触れるとおり、この表と手元のバージョンが一致しているとは限りません。ですから最初にやるべきことは、表を読むことではなく、自分の環境が実際に何を渡してくるかを1回だけファイルへ落とすことです。
#!/usr/bin/env bash
# ~/.gemini/antigravity-cli/statusline.sh (調査用・使い捨て)
# 目的: 手元のバージョンが実際に渡してくるペイロードを1回だけ保存する
DUMP="$HOME/.gemini/antigravity-cli/payload-sample.json"
payload="$(timeout 2 cat)"
# すでに採取済みなら上書きしない(毎回の状態変化で書き換わると調査にならない)
if [ -n "$payload" ] && [ ! -s "$DUMP" ]; then
printf '%s\n' "$payload" > "$DUMP"
fi
echo "sampling..."
これを仕込んで CLI を一度起動し、payload-sample.json を開けば話が早いです。私はこの一手間を飛ばしてドキュメントの表だけを見て書き始めたので、後述する回り道をしました。
timeout 2 cat になっている理由は次の節です。
素朴に標準入力を読むと、スクリプトが時間内に戻らないことがある
最初に書いたのは、こういう1行でした。
payload="$(cat)"
普段のシェルスクリプトなら何の問題もない書き方です。ところがステータスラインのスクリプトには実行時間の予算があり、その中で標準出力を返して終了しなければなりません。そして認証トークンの更新中や会話の再開中には、CLI 側が標準入力のパイプを開いたまま、何も書かず閉じもしない状態が起こり得ます。cat は入力の終端を待ち続けるので、この状態に当たると戻ってきません。
手元で再現して、2つの書き方の挙動を測りました。親プロセスがパイプを保持したまま書き込みをしない状況を作り、スクリプト自身の所要時間と終了コードを記録した結果です。
| 標準入力の読み方 | 親が書いて閉じる(正常系) | 親が開いたまま書かない | 終了コード |
payload="$(cat)" | 4 ms で読み取り | 予算を超えても戻らず、外側から強制終了 | 124 |
payload="$(timeout 2 cat)" | 4 ms で読み取り | 2,004 ms で空文字を返して終了 | 0 |
終了コード 124 は、時間切れで殺されたことを示します。画面上は「ステータスラインが更新されない」「入力が一瞬もたつく」といった曖昧な症状として現れるので、原因がスクリプトの読み取り方にあると気づくまでに時間がかかります。
直し方は、必ず時間内に戻ることを保証したうえで、読めなかったときの逃げ道を用意しておくことです。
#!/usr/bin/env bash
# 必ず予算内に戻るステータスライン
CACHE="$HOME/.gemini/antigravity-cli/.statusline-last"
payload="$(timeout 2 cat)"
if [ -z "$payload" ]; then
# 読めなかったときは前回の表示を出して静かに終わる。
# ここで exit 1 にすると、画面にエラーが出続けて余計に読みにくくなる。
cat "$CACHE" 2>/dev/null || echo "agy"
exit 0
fi
line="$(printf '%s' "$payload" | python3 -c '
import sys, json
p = 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"
read -r -t 2 で1行だけ読む書き方も試しましたが、ペイロードが整形されて複数行で届く場合に先頭行しか取れません。timeout 2 cat なら複数行でもそのまま読めて、時間切れの保証も残ります。
もう一点、意外だったところを添えておきます。( sleep 20 ) | timeout 8 ./statusline.sh のような形で手元検証をすると、スクリプト自体は2秒で戻っているのに、シェルはパイプラインの左側が終わるまで待つため、体感では20秒待たされます。スクリプトが遅いのか検証の書き方が悪いのかを取り違えやすい場面でした。所要時間はスクリプトの内側で測るのが確実です。
# スクリプトの内側で測る(外側の time はパイプライン全体を計ってしまう)
S=$(date +%s%N)
payload="$(timeout 2 cat)"
E=$(date +%s%N)
echo "took=$(( (E - S) / 1000000 ))ms" >&2
cost フィールドは、来る環境と来ない環境がある
ここが2つ目のつまずきでした。CLI 1.1.21 のリリースノートには、ステータスラインのデータモデルに丸めなしのコストが加わったと書かれています。ところが公式ドキュメントの「Available JSON fields」の表を確認すると、cwd・session_id・conversation_id・model・context_window・quota・plan_tier などは並んでいるものの、コストに相当する項目が見当たりません。ドキュメントに載っているペイロード例の version も、現行より古い値のままです。
つまりリリースノートと参照ドキュメントの更新にずれがあります。私はこの表を先に読んでいたので、「まだ来ていない」と早合点しかけました。実際に確かめる方法は前述のダンプだけです。
判断としては、片方に賭けずに両対応で書くのが妥当だと考えています。
printf '%s' "$payload" | python3 -c '
import sys, json
p = json.load(sys.stdin)
# cost は環境によって来ないことがある。
# 無いときに落ちないよう、必ず get() で降りて既定値を用意する。
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:
# 代わりにトークンの内訳を出しておけば、あとから単価を当てて再計算できる
print("in {} / out {} / cached {}".format(
usage.get("input_tokens", 0),
usage.get("output_tokens", 0),
usage.get("cache_read_input_tokens", 0),
))
'
この書き方には副次的な利点があります。current_usage には input_tokens・output_tokens・cache_creation_input_tokens・cache_read_input_tokens の4つが入っています。この4つさえ貯めておけば、単価表が変わっても、あるいは自分の契約が変わっても、過去分をさかのぼって計算し直せます。金額だけを保存していると、それができません。
契約している単価そのものは記事に書きません。プランごとに違いますし、時期によっても変わるためです。ご自身の利用枠と料金を確認したうえで、単価表は1つの設定ファイルに切り出しておくことをおすすめします。金額の計算式が複数のスクリプトに散っている状態は、あとから必ず面倒になります。
台帳を素朴に作ると、5割多く見える
3つ目です。ステータスラインは「エージェントの状態が変わるたびに」実行されます。idle から thinking へ、thinking から tool_use へ、といった遷移のたびに呼ばれるので、1つの会話の中で何度も走ります。
そして context_window.total_input_tokens は、その会話における累計です。呼ばれるたびに増えていきます。ここを取り違えて、呼ばれるたびに1行ずつ台帳へ追記し、あとで全行を合計すると何が起きるか。手元で再現しました。1つの会話が3回、別の会話が1回呼ばれたケースです。
| 集計の仕方 | 合計トークン | 実際との差 |
| 台帳の全行を足す | 15,930 | +5,300(約 50% 過大) |
| 会話 ID ごとに最終行だけ採る | 10,630 | — |
累計値を何度も足しているので当然の結果ですが、台帳に並ぶ数字はそれらしく見えます。「今週は思ったより使っている」という誤った実感だけが残り、しかも過大の度合いは会話の長さによって変わるので、作業種別ごとの比較まで歪みます。
会話 ID で最後の値だけを採るようにします。
#!/usr/bin/env bash
# ステータスラインから呼ばれる記録部分(表示処理とは分けておく)
LEDGER="$HOME/.gemini/antigravity-cli/usage.jsonl"
printf '%s' "$payload" | python3 -c '
import sys, json, os
p = 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
集計側は、会話 ID ごとに最後の行を採ります。
#!/usr/bin/env python3
"""usage.jsonl を会話単位に畳んで、作業ディレクトリごとに集計する。
追記された全行を足すと累計値を二重計上するため、必ず最終行だけを採る。"""
import json
import os
from collections import defaultdict
LEDGER = 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 # 書き込み途中の行は捨てる
cid = row.get("conversation_id")
if cid:
last[cid] = row # 同じ会話は後から来た行で上書きする
by_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}")
cwd で束ねているのが要点です。リポジトリごとに集計されるので、「どのプロジェクトに寄っているか」がそのまま出ます。複数のプロダクトを並行で触っている場合、これが一番知りたい切り口でした。
丸めた表示が隠すのは、単価ではなく回数
ここまでで台帳ができました。では、丸めなしの値にこだわる意味はどれくらいあるのか。素朴に考えると、小数第2位で丸めても誤差は1セッションあたり最大で1セント弱ですから、大した違いはなさそうに思えます。
そうならないのは、セッションの数が偏っているときです。3つの作業グループを想定して計算しました。
| 作業グループ | 実額 | 1セント単位で丸めた合計 | 実額での構成比 | 丸めた表示での構成比 |
| 設計・大きな改修(1回 $0.62 × 20 セッション) | $12.40 | $12.40 | 69.1% | 77.5% |
| 小さな質問・補完(1回 $0.004 × 620 セッション) | $2.48 | $0.00 | 13.8% | 0.0% |
| 定型の検証実行(1回 $0.017 × 180 セッション) | $3.06 | $3.60 | 17.1% | 22.5% |
1セッションあたりの額が丸めの単位を下回るグループは、何回走らせても合計が $0.00 のままです。実額では全体の 13.8% を占めているのに、丸めた表示では存在しないことになります。金額の絶対値は小さくても、構成比が歪むほうが問題です。「どこを削るか」を構成比で決めているなら、判断がそのまま狂います。
隠れる額は単価ではなく回数で決まる、というのがここでの構造です。単価の高い作業は丸めの影響を受けません。影響を受けるのは、安くて数が多い作業のほうです。そして自動化を進めるほど、その種類の呼び出しは増えていきます。
台帳に排他ロックは要るのか
複数のリポジトリを同時に開いて作業していると、複数の CLI が同じ usage.jsonl へ同時に追記します。最初は排他ロックを入れるつもりでいました。行が混ざって壊れるだろうと考えたからです。
先に確かめました。6プロセスから同じファイルへ O_APPEND で並行追記し、1行あたりの長さを変えて、パースできない行が出るかを数えた結果です。
| 1行の長さ | 書き込み方 | 全行数 | パースできなかった行 |
| 約 132 バイト | os.write で1回 | 240 | 0 |
| 約 8,032 バイト | os.write で1回 | 240 | 0 |
| 約 8,030 バイト | バッファ付き open(path, "a") | 240 | 0 |
| 約 20,030 バイト | バッファ付き open(path, "a") | 240 | 0 |
1行も壊れませんでした。O_APPEND を付けたファイル記述子への書き込みは、オフセットの取得と書き込みが分割されないため、行の途中に別プロセスの内容が割り込みません。バッファ付きで書いた場合も、with を抜けるところで1回にまとめて書き出されるので同じ結果になりました。
ですからこの場合は、排他ロックを足さないことを推奨します。ロックは失敗経路を増やします。ステータスラインは時間の予算が厳しいので、ロック待ちが入るとそれ自体が先ほどの時間切れの原因になります。壊れない前提が成立しているなら、何もしないのが最も安全です。
ただし前提には条件があります。ここが注意点で、私は最初にこれを踏みかけました。
- 1行を1回で書き切ること。 途中で
flush を挟んだり、本文と改行を別々に書いたりすると、その隙間に別プロセスが入ります。f.write(json.dumps(row) + "\n") のように、改行まで含めて1つの文字列にしてから渡します。
- 行を巨大にしないこと。 台帳に payload 全体を入れたくなりますが、必要なのは会話 ID・cwd・トークン内訳だけです。行が長くなるほど、環境によっては分割される余地が増えます。
- 同期フォルダやネットワーク越しに置かないこと。 私は普段リポジトリを同期フォルダの下に置いているので、うっかり台帳もそこへ作りかけました。クラウド同期の下では、追記の途中の状態がそのまま別プロセスへ見えることがあります。台帳はローカルディスクに置いて、必要なら集計結果だけを持ち出す形にしています。
この3点を守れなくなったとき、初めてロックを検討すれば十分だと考えています。本番のサービスではなく手元の計測用ファイルですから、壊れたら消してやり直せる、という前提も込みでの判断です。
画面に出すものと、ファイルに落とすものを分ける
3か所を直した結果、私は表示と記録をはっきり分けることにしました。ステータスラインは実行時間の予算が厳しいので、重い処理を混ぜると先ほどの時間切れに近づきます。
| 置き場所 | 載せるもの | 理由 |
| ステータスライン(毎回描画) | モデル名・ブランチ・コンテキスト使用率・当日累計の概算 | 目で追って意味があるのは、いま止めるかどうかを決められる情報だけ |
| JSONL 台帳(追記のみ) | 会話 ID・cwd・トークン内訳・cost(あれば) | あとから単価を当て直せる形で残す |
| 週次の集計スクリプト(手で実行) | ディレクトリ別・モデル別の集計 | 判断は週単位で十分。毎秒計算する必要はない |
記録側の書き込みは || true で握りつぶしています。台帳の書き込みに失敗したせいでステータスラインが出なくなるのは、優先順位が逆だからです。ただし握りつぶす範囲は最小にしておかないと、今度は台帳が空のまま何週間も気づかないことになります。私は集計スクリプトの冒頭で、台帳の最終更新時刻が2日以上前なら警告を出すようにしました。設定や仕込みが黙って効かなくなる問題は、無視された設定キーが CI を通り抜ける経路でも扱いましたが、静かに失敗する経路は必ず自分で塞ぐ必要があります。
同じ理由で、記録スクリプトの終了コードにも注意が要ります。ラッパーを一枚かませると、内側の失敗が外側に伝わらなくなることがあります。この種の取りこぼしについては終了コードがパイプに握りつぶされる話にまとめてあります。
台帳ができたあと、何を止めるかをどう決めたか
数字が揃うと、削る候補は素直に見えると思っていました。実際に見えたのは、もう少し扱いにくい形です。
いちばん高いディレクトリは、いちばん価値のある作業をしている場所でもありました。ここを削るのは筋が悪いです。一方で、単価が低く回数が多いグループ、つまり丸めた表示では見えなかった側は、削っても失うものが少ない場合がありました。同じ確認を何度も投げていたり、一度読めば済むファイルを都度読ませていたりします。
私はこの判断に、金額そのものではなく「1セッションあたりの平均トークン」を使うようにしました。平均が極端に小さいセッションが大量にある場合、それは作業ではなく癖です。逆に平均が大きいセッションは、たいてい必要があってそうなっています。集計スクリプトで avg を出しているのは、この見方をしたかったからです。
金額を見て慌てるより、分布を見て癖を直すほうが、結果として残る作業の質が上がりました。まだ2週間ほどの運用ですので、季節性のようなものは掴めていません。それでも、月末の請求書を見て漠然と反省するだけの状態からは抜けられたと感じています。
次にやること
順番としては、次の3つを分けて進めるのが早いと思います。
- 調査用の使い捨てスクリプトを1本置いて、
payload-sample.json を1回だけ採る。手元のバージョンに cost が来ているかどうかは、そこにしか答えがありません
- 表示だけの最小のステータスラインに差し替えて、
timeout 2 cat とフォールバックが効いていることを確かめる
- その後に記録側を足し、1週間ためてから集計スクリプトを回す
先に記録側から作ると、時間切れの症状と集計の誤りが同時に出て切り分けができなくなります。私はこの順番を守らなかったので、二重計上に気づくまでに余計な時間を使いました。ドキュメントの表を読んで判断しようとするのも、同じ種類の回り道です。
私自身まだ運用しながら形を変えている途中ですが、共に学んでいけたら嬉しいです。お読みいただきありがとうございました。