Claude Code の足もとに、18分類の計器盤を。ステータスラインを作りました
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
Claude Code の入力欄の下に、レート制限・コンテキスト・料金・作業の密度まで18分類の指標を並べるステータスラインを作りました。数字の出どころ、率を倍率と残り時間へ翻訳する計算、端末に黙って切られない並べ方、点滅の作り方、導入と検証まで、実画面の数字を検算しながら図20点で読み解きます。
数字はどこから来るのか
前のページで、ステータスラインは「標準入力で状態を受け取り、文字を print するコマンド」だと確かめました。では、画面のすべての数字が標準入力に入っているのかというと、そうではありません。今日1日の料金も、PR の CI の結果も、別の AI ツールの枠も、標準入力には入っていません。このページでは、画面の数字をどこから集めているかと、集めるときに踏んだ落とし穴を見ていきます。
数字の出どころを数えると、6系統ありました。1つ目は、Claude Code が渡してくれる標準入力のJSONです。2つ目は、このスクリプト自身が手元に残している記録(キャッシュ)。3つ目から5つ目は外の道具で、料金の推計をするccusage、GitHub の状態を調べるgh、そしてCodexが手元に書き残すログです。6つ目は、CPU やバッテリーを調べる OS のコマンドです。
| 出どころ | この画面の数字 | 覚えておく時間 | 性質 |
|---|---|---|---|
| 標準入力の JSON | Context・Sess・Cache・Tokens・Env | 覚えない(毎回届く) | 公式の値 |
| レート制限の最終値 | ⚡ Claude(届かない描画の穴埋め) | 7日 | 公式の値の写し |
| セッションの記録 | ETA・compact・Turns・Active | セッションのあいだ | 手元で積み上げた記録 |
| ccusage | Today・Blk・$/h | 10秒(ccusage の内蔵) | 推計 |
| gh | PR・CI・レビュー | 300秒(無いという結果も) | GitHub の値 |
| Codex のログ | 🤖 Codex の7日枠・残高 | データを60秒 | Codex が記録した値 |
| Anthropic の障害情報 | 🚨 Alert | 300秒 | 公式の状態ページ |
| OS のコマンド | CPU・Mem・Disk・Bat・Proc | 覚えない(2秒で打ち切り) | その場の実測 |
表の右端の「性質」の列に注目してください。同じ💰 Cost の行に並んでいても、Sess(このセッションの料金)は標準入力の公式の値で、Today と Blk は ccusage が利用記録から計算した推計です。出どころが違うので、Sess と Today の差が、そのまま他のセッションで使った金額になるとは限りません。画面の上では同じように並びますが、読むときには、どこから来た数字かを思い出す必要があります。
もう1つの決まりは、どれか1つが失敗しても、画面全体を壊さないことです。外の道具を呼ぶところは、すべて時間の上限を付けたうえで、失敗したらそのチップを出さずに先へ進むように書いています。
try:
cc = subprocess.run(
["ccusage", "statusline", "--refresh-interval", "10"],
input=json.dumps(data), capture_output=True, text=True, timeout=15, env=env,
).stdout
cc = strip_ansi(cc)
m = re.search(r"\$([\d,.]+) today", cc)
if m:
parts3.append(C_MONEY + f"Today ${m.group(1)}" + R)
# …(block・$/hr も同じ形で取り出す)
except Exception:
passccusage が入っていないパソコンでも、呼び出しが失敗するだけで、Today・Blk・$/h の3つのチップが出ないだけです。ほかの行はいつもどおり表示されます。道具が入っていないことを、エラーの赤い文字で知らせることもしません。計器盤の一部が欠けても、残りの計器は読めるほうが、運転中には助かるからです。
ちなみに、標準入力にどんな項目が届いているかを確かめるのにも、ひと工夫しています。スクリプトは描画のたびに、届いた JSON をそのまま ~/.cache/claude-statusline-last-input.json へ書き出しています。公式ドキュメントに載っていない項目(たとえば fast_mode)も、このファイルを開いて見つけました。ある項目が届くかどうかは、推測せず、届いた実物で確かめる。この習慣が、次の節の発見にもつながりました。
最初の描画には、レート制限が載っていない
作り始めてすぐ、奇妙なことに気づきました。新しいセッションを始めると、⚡ Claude の行が出ないのです。しばらくやり取りをすると、いつの間にか現れます。コードの誤りを疑いましたが、先ほどの書き出したファイルを開いてみると、原因はすぐに分かりました。セッションの最初の描画では、標準入力の JSON に rate_limits の項目そのものが無かったのです。レート制限の値は、最初の応答が返ってきたあとの描画から載ってきます。
これは Claude Code の不具合ではなく、そういう作りだと考えるのが自然です。枠の使用率は、サーバーとやり取りしてはじめて分かる値なので、まだ1回も応答を受け取っていない時点では、渡しようがありません。ただ、表示する側としては困ります。セッションを始めた瞬間こそ、「いま枠はどれくらい残っているか」を知りたいからです。
そこで、最後に見た値を覚えておき、値が無い描画ではそれで補うことにしました。値が届いた描画では、そのときの時刻と一緒にファイルへ保存します。値が届かない描画では、そのファイルを読んで、7日以内に保存されたものなら使います。
RL_CACHE = os.path.join(CACHE_DIR, "claude-rate-limits.json")
rl_seen_at = None
if rl:
with open(RL_CACHE, "w") as f:
json.dump({"ts": time.time(), "rl": rl}, f)
else:
with open(RL_CACHE) as f:
saved = json.load(f)
if time.time() - saved.get("ts", 0) < 7 * 24 * 3600:
rl = saved.get("rl") or {}
rl_seen_at = saved["ts"]ここで大事なのは、補った値を、新しい値のふりをして出さないことです。rl_seen_at という変数には、補ったときにだけ、その値を見た時刻が入ります。表示の側では、補った値が6時間より古ければ、「(9/27)」のように見た日付を添えます。数時間前の値なら、枠の使用率はそれほど動いていないので、日付を添えずに読んでも大きくは外れません。けれど、何日も前の値を、今の値と同じ顔で出すと、画面が嘘をつくことになります。
無い瞬間は最後に見た値で補い、古ければ日付を添える。空欄よりも役に立ち、でっち上げよりも正直な、中間の選び方です。
また、この補った値は、3ページ目で扱う「枯渇予測」の計算には使いません。予測は、値が増えていく速さから求めるので、古い値を混ぜると速さを見誤るからです。コードでは、rl_seen_at が空(つまり標準入力から届いた新鮮な値)のときだけ、予測のための記録に積むようにしています。表示のために補った値と、計算に使ってよい値を分けておくのも、この節で学んだことの1つです。
キャッシュするのは、絵ではなくデータ
次は、キャッシュの持ち方で失敗した話です。🤖 Codex の行は、Codex が手元に残すログ(~/.codex/sessions の下にある、会話ごとのファイル)を読んで作っています。いちばん新しいファイルを探し、中から rate_limits という項目を探し出す処理です。1ページ目で確かめたとおり、ステータスラインは会話が動いているあいだ何度も呼ばれるので、毎回ログを探していては重すぎます。そこで、結果を60秒覚えておくことにしました。
最初の実装では、できあがったチップの文字列(色のコードも込み)を60秒覚えていました。重い処理の結果をそのまま使い回すのは、ごく自然な発想です。ところが、しばらく使っていて、おかしなことに気づきました。警告の点滅(4ページ目で作り方を見ます)が、ある瞬間の色のまま止まって動かないのです。リセットまでの残り時間も、60秒のあいだ同じ値で固まっていました。
原因は、覚えていたものが「絵」だったことです。点滅は、描画のたびに、そのときの秒に合わせて赤い帯か通常の色かを選んで作ります。残り時間も、描画のたびに「リセットの時刻 − 今の時刻」を計算して作ります。つまり、時刻で変わる見た目は、描画のたびに作り直さなければならないのに、できあがった絵を60秒貼りっぱなしにしたせいで、その作り直しが止まっていたのです。
直し方は、覚えるものを変えることでした。重いのは「ログのファイルを探して、中から値を取り出す」ことだけです。そこで、取り出した値(使用率とリセットの時刻)だけを60秒覚え、色と残り時間と点滅は、描画のたびに計算し直すようにしました。
def codex_rate_data():
"""Codex ログの走査(60秒キャッシュ)。rate_limits とログ最終更新時刻を返す。
キャッシュするのはデータのみ——描画結果を固定すると点滅フレームが止まるため。"""
cache = os.path.join(CACHE_DIR, "codex-rate-statusline.json")
try:
if time.time() - os.path.getmtime(cache) < 60:
with open(cache) as f:
return json.load(f)
except Exception:
pass
# …(キャッシュが古ければ、ログを探し直して値を取り出す)関数の説明文に、この失敗をそのまま書き残しました。次にこのコードを触る人(未来の自分を含みます)が、「描画結果ごと覚えたほうが速いのでは」と考えて同じ穴に落ちないためです。キャッシュは、何を覚えるかまで含めて設計する。覚えてよいのは時刻で変わらないものだけで、時刻で変わるものは、覚えたデータから毎回作ります。
「PRはありません」も、覚えておく
キャッシュの話をもう1つ続けます。🌿 Git の行には、いまのブランチに PR があれば、その番号と CI の結果、レビューの判定を出しています。調べるのはghの役目で、GitHub に問い合わせるので、呼ぶたびに少し時間がかかります。そこで、結果を300秒覚えることにしました。ここまでは Codex のときと同じです。
気をつけたのは、PR が無いブランチのときです。作業を始めたばかりのブランチには、まだ PR がありません。このとき「PR は無かった」という結果を覚えずにいると、次の描画でもまた gh を呼び、また「無い」と言われ、その次の描画でも……と、無いものを探し続けることになります。
どれくらい無駄になるかを、ざっと計算してみます。描画が300ミリ秒ごとに休まず続いたとすると、5分(300秒)のあいだに 300 ÷ 0.3 = 1,000回、gh を起動することになります。実際には会話が止まっている時間もあるので、ここまで多くはなりませんが、それでも「無い」を確かめるためだけに GitHub へ何百回も問い合わせるのは、明らかに無駄です。
そこで、「無い」という結果も、答えの1つとして300秒覚えることにしました。このやり方は否定結果のキャッシュと呼ばれます。コードで見ると、ほんの少しの違いです。
def pr_chip(cwd, br):
cache_f = os.path.join(CACHE_DIR, "claude-sl-pr.json")
key = f"{cwd}:{br}"
now = time.time()
try:
with open(cache_f) as f:
c = json.load(f)
if c.get("key") == key and now - c.get("ts", 0) < 300:
return c.get("chip") or ""
except Exception:
pass
chip = ""
# …(gh pr view で調べ、PR があれば chip を組み立てる。無ければ空のまま)
with open(cache_f, "w") as f:
json.dump({"key": key, "ts": now, "chip": chip}, f)
return chipポイントは最後の書き込みです。PR が無くて chip が空のままでも、その空の結果を時刻と一緒に保存しています。次の描画では、保存した結果が300秒以内なら、中身が空でもそのまま返します。「空の答え」と「まだ調べていない」を、ファイルがあるかどうかで区別しているわけです。
もう1つの工夫は、覚える単位(キー)を「作業フォルダ:ブランチ名」にしていることです。ブランチを切り替えたり、別のフォルダで作業を始めたりすると、キーが変わるので、古い「無い」を使い回さずに調べ直します。反対に、PR を作った直後は、覚えている「無い」が最大300秒残るので、画面に出るまで少し待つことになります。5分の遅れと、数百回の無駄な問い合わせを天秤にかけて、遅れのほうを選びました。
パソコンの状態は、2秒で打ち切る
💻 System と🧾 Meta の行には、パソコン自体の状態を並べています。時刻、CPU の負荷、空いているメモリ、ディスクの空き、バッテリー、同時に動いている Claude Code の数です。どれも OS に備わっているコマンドや関数で調べられます。CPU は直近1分の平均の負荷(ロードアベレージ)をコアの数と並べ、メモリは vm_stat というコマンドの出力から、すぐに使える状態のメモリの量を足し合わせて出しています。
ここでの決まりは、どのコマンドにも2秒の時間の上限を付け、失敗したらそのチップを消すことです。パソコンが重くなっているときほど、こうしたコマンドの返事も遅くなります。そのときにステータスラインが返事を待ち続けると、画面の表示そのものが止まってしまいます。パソコンが重いという情報を出すために、画面を重くしては本末転倒です。
端末を開いてからの時間(Term up)を出すところでは、2ページ目の最初に書いた「つながっている端末が無い」場合に、実際にぶつかりました。ふつうは、自分がつながっている端末の名前を調べ、その端末で最初に動き出したプログラムの時刻を見れば、端末を開いた時刻が分かります。ところが、ステータスラインのコマンドには、その端末が無いことがありました。そこで、自分を起動した親のプロセス、そのまた親、と順にさかのぼり、ログインのときに動き出したシェルを見つけて、その起動時刻を使うようにしています。1つの方法がだめでも、別の道から同じ答えにたどり着くように、2段の構えにしました。
🧾 Meta の行の👤は、いま Claude Code にログインしているアカウントを示します。複数のアカウントを使い分けている人が、取り違えに気づけるようにするためです。アカウントの情報は ~/.claude.json というファイルから読みますが、このファイルは数メガバイトの大きさになることがあるので、読んだ結果を1時間覚えておきます。なお、この記事の画面では、👤 の中身を伏せています。画面を人に見せるときは、アカウントや金額のような、自分だけが知っていればよい値を先に隠す。ステータスラインを作ると、画面の写しを共有する機会も増えるので、覚えておきたい習慣です。
外の知らせは、何も無いときは黙っている
画面のいちばん上に割り込む🚨 Alert は、Anthropic が公開しているサービスの状態ページを読んで作っています。状態ページには、ふだんは「問題なし」を意味する none という値が入っていて、障害が起きると minor・major・critical といった値に変わります。このスクリプトは、none のときには何も表示せず、それ以外のときだけチップを出します。major と critical のときは、さらに点滅させます。
r = subprocess.run(["curl", "-sf", "-m", "2",
"https://status.anthropic.com/api/v2/status.json"],
capture_output=True, text=True, timeout=3)
j = json.loads(r.stdout)
ind = (j.get("status") or {}).get("indicator") or "none"
desc = (j.get("status") or {}).get("description") or ""
if ind != "none":
col = C_BAD if ind in ("major", "critical") else C_WARN
chip = f"{col}🌐 API {ind}: {desc[:30]}{R}"
if ind in ("major", "critical"):
chip = blinkify(chip)返事が遅いときに待ち続けないよう、curl には2秒の上限を付け、結果は300秒覚えておきます。Claude Code の返事が急に遅くなったとき、自分の側の問題なのか、サービス側の障害なのかは、画面を見ただけでは分かりません。障害のときだけ現れる行があれば、「待てば直るもの」と「自分で直すもの」を、調べる前に見分けられます。
同じように、🧾 Meta の行の末尾には、Claude Code に新しい版が出たときだけ「🆙 Update」のチップが出ます。npm という配布の仕組みに登録された最新の版の番号を、6時間ごとに確かめ、いま動いている版より新しいときだけ表示します。版の番号は「2.1.285」のように点で区切られているので、文字列のまま比べると「2.1.99」のほうが大きいと判定されてしまいます。そこで、点で区切った数字の並びとして比べています。
セッションごとの記録は、IDをファイル名に
このページの最後に、手元に積み上げている記録の置き方を見ておきます。3ページ目で扱う作業時間(Active)や、会話を詰め直した回数(compact)、やり取りの回数(Turns)は、1回の描画だけでは分かりません。前の描画のときの値と比べて、はじめて増えたか減ったかが分かります。そのため、描画のたびに値を記録し、次の描画で読み返す必要があります。
ここで困るのが、Claude Code を同時にいくつも開いている場合です。冒頭の画面の🧾 Meta の行にも「⛵ Proc ×4」とあり、この朝は4つの Claude Code が同時に動いていました。記録のファイルが1つしかないと、4つのセッションが同じファイルを書き換え合い、ある会話の作業時間に、別の会話の時間が混ざってしまいます。
そこで、記録はセッションIDをファイル名にした、セッションごとのファイルに分けました。~/.cache/claude-sl-state/ というフォルダの下に、「セッションID.json」というファイルがセッションの数だけできます。ファイルが分かれていれば、書き換え合いは起こりようがありません。順番待ちの仕組みを作る代わりに、ぶつかる場所そのものを無くす設計です。この考え方は、ZOZO の技術ブログでステータスラインの compact 検出を扱った記事から学びました。
spath = os.path.join(sdir, f"{sid}.json")
samples = st.get("samples") or []
if samples and used_tok < samples[-1][1] * 0.6 and samples[-1][1] > 100_000:
st["compact"] = st.get("compact", 0) + 1
samples = [] # 圧縮後は増加率を測り直す
if not samples or used_tok != samples[-1][1]:
samples.append([time.time(), used_tok])
st["samples"] = samples[-20:]
pid = data.get("prompt_id")
if pid and pid != st.get("last_prompt"):
st["last_prompt"] = pid
st["turns"] = st.get("turns", 0) + 1compact の見つけ方は、使っているトークンの数の急な減り方です。直前の記録が10万トークンを超えていて、今回の値がその6割未満まで急に減っていたら、会話が詰め直されたとみなして1回数えます。やり取りの回数は、標準入力の prompt_id という項目が変わった回数で数えます。どちらも、Claude Code が「compact しました」「1回やり取りしました」と教えてくれるわけではないので、届いた値の変化から、起きたことを推し量っています。推し量りなので、6割という境目の近くでは数え漏れもありえます。
トランスクリプト(会話の記録ファイル)から道具の使用回数を数えるところでは、小さな落とし穴も踏みました。私の環境では、grep という名前が、別の検索道具(ugrep)を呼ぶように設定されていて、同じ検索の書き方が正規表現のエラーになったのです。そこで、スクリプトの中では /usr/bin/grep と、置き場所まで書いて呼ぶようにしました。grepで5MB の記録を数えるのに約0.05秒と測れたので、結果は30秒だけ覚えるようにしています。
考えてみる:キャッシュの時間を、全部そろえて60秒にしてはいけないのだろうか?
そろえると設定は簡単になりますが、データごとの「変わる速さ」と「調べる重さ」が違うので、どこかで無理が出ます。PR や CI の結果は数分単位でしか変わらないのに、60秒ごとに GitHub へ問い合わせるのは多すぎます。反対に、トランスクリプトの道具の回数は、作業中は数秒ごとに増えるので、300秒も覚えると画面が古くなります。この記事の実装では、変わる速さ(CI なら数分、ツールの回数なら数秒)と、調べる重さ(ネットワーク越しか、手元のファイルか)の両方から、1つずつ時間を決めています。あなたの環境で「この値はどれくらいの頻度で変わるか」を1つずつ書き出してみると、それぞれのちょうどよい時間が見えてくるはずです。











このページ(2ページ目)の感想・質問・設計へのコメント