PROMARI JOURNAL

Claude Code の足もとに、18分類の計器盤を。ステータスラインを作りました

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

Claude Code の入力欄の下に、レート制限・コンテキスト・料金・作業の密度まで18分類の指標を並べるステータスラインを作りました。数字の出どころ、率を倍率と残り時間へ翻訳する計算、端末に黙って切られない並べ方、点滅の作り方、導入と検証まで、実画面の数字を検算しながら図20点で読み解きます。

PASS IT ON

ひとつの発見を、次の会話へ。

数字はどこから来るのか

前のページで、ステータスラインは「標準入力で状態を受け取り、文字を print するコマンド」だと確かめました。では、画面のすべての数字が標準入力に入っているのかというと、そうではありません。今日1日の料金も、PR の CI の結果も、別の AI ツールの枠も、標準入力には入っていません。このページでは、画面の数字をどこから集めているかと、集めるときに踏んだ落とし穴を見ていきます。

数字の出どころを数えると、6系統ありました。1つ目は、Claude Code が渡してくれる標準入力のJSONです。2つ目は、このスクリプト自身が手元に残している記録(キャッシュ)。3つ目から5つ目は外の道具で、料金の推計をするccusage、GitHub の状態を調べるgh、そしてCodexが手元に書き残すログです。6つ目は、CPU やバッテリーを調べる OS のコマンドです。

図2-1 数字の出どころは、6系統ある
図2-1 数字の出どころは、6系統ある。現在のしくみ。公式の値・自分で積んだ記録・外の道具の推計を、同じ画面に並べています。出どころが違う数字は、同じ意味とは限りません。図2-1 数字の出どころは、6系統ある。現在のしくみ。公式の値・自分で積んだ記録・外の道具の推計を、同じ画面に並べています。出どころが違う数字は、同じ意味とは限りません。
数字の出どころと、覚えておく時間(キャッシュの寿命)
出どころこの画面の数字覚えておく時間性質
標準入力の JSONContext・Sess・Cache・Tokens・Env覚えない(毎回届く)公式の値
レート制限の最終値⚡ Claude(届かない描画の穴埋め)7日公式の値の写し
セッションの記録ETA・compact・Turns・Activeセッションのあいだ手元で積み上げた記録
ccusageToday・Blk・$/h10秒(ccusage の内蔵)推計
ghPR・CI・レビュー300秒(無いという結果も)GitHub の値
Codex のログ🤖 Codex の7日枠・残高データを60秒Codex が記録した値
Anthropic の障害情報🚨 Alert300秒公式の状態ページ
OS のコマンドCPU・Mem・Disk・Bat・Proc覚えない(2秒で打ち切り)その場の実測

表の右端の「性質」の列に注目してください。同じ💰 Cost の行に並んでいても、Sess(このセッションの料金)は標準入力の公式の値で、Today と Blk は ccusage が利用記録から計算した推計です。出どころが違うので、Sess と Today の差が、そのまま他のセッションで使った金額になるとは限りません。画面の上では同じように並びますが、読むときには、どこから来た数字かを思い出す必要があります。

もう1つの決まりは、どれか1つが失敗しても、画面全体を壊さないことです。外の道具を呼ぶところは、すべて時間の上限を付けたうえで、失敗したらそのチップを出さずに先へ進むように書いています。

Pythonstatusline.py(ccusage を呼ぶところ・抜粋)
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:
    pass

ccusage が入っていないパソコンでも、呼び出しが失敗するだけで、Today・Blk・$/h の3つのチップが出ないだけです。ほかの行はいつもどおり表示されます。道具が入っていないことを、エラーの赤い文字で知らせることもしません。計器盤の一部が欠けても、残りの計器は読めるほうが、運転中には助かるからです。

ちなみに、標準入力にどんな項目が届いているかを確かめるのにも、ひと工夫しています。スクリプトは描画のたびに、届いた JSON をそのまま ~/.cache/claude-statusline-last-input.json へ書き出しています。公式ドキュメントに載っていない項目(たとえば fast_mode)も、このファイルを開いて見つけました。ある項目が届くかどうかは、推測せず、届いた実物で確かめる。この習慣が、次の節の発見にもつながりました。

最初の描画には、レート制限が載っていない

作り始めてすぐ、奇妙なことに気づきました。新しいセッションを始めると、⚡ Claude の行が出ないのです。しばらくやり取りをすると、いつの間にか現れます。コードの誤りを疑いましたが、先ほどの書き出したファイルを開いてみると、原因はすぐに分かりました。セッションの最初の描画では、標準入力の JSON に rate_limits の項目そのものが無かったのです。レート制限の値は、最初の応答が返ってきたあとの描画から載ってきます。

これは Claude Code の不具合ではなく、そういう作りだと考えるのが自然です。枠の使用率は、サーバーとやり取りしてはじめて分かる値なので、まだ1回も応答を受け取っていない時点では、渡しようがありません。ただ、表示する側としては困ります。セッションを始めた瞬間こそ、「いま枠はどれくらい残っているか」を知りたいからです。

図2.1-1 最初の描画には、レート制限が載っていない
図2.1-1 最初の描画には、レート制限が載っていない。実測。無い瞬間がある値は、最後に見た値で補い、古さを日付で正直に添えます。空欄にも、でっち上げにもしません。図2.1-1 最初の描画には、レート制限が載っていない。実測。無い瞬間がある値は、最後に見た値で補い、古さを日付で正直に添えます。空欄にも、でっち上げにもしません。

そこで、最後に見た値を覚えておき、値が無い描画ではそれで補うことにしました。値が届いた描画では、そのときの時刻と一緒にファイルへ保存します。値が届かない描画では、そのファイルを読んで、7日以内に保存されたものなら使います。

Pythonstatusline.py(rate_limits の最終値で補う・例外処理を省いた抜粋)
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秒のあいだ同じ値で固まっていました。

図2.2-1 キャッシュするのは、絵ではなくデータ
図2.2-1 キャッシュするのは、絵ではなくデータ。実測。重いのは「ログを探す」ことだけです。覚えるのはその結果のデータにして、時刻で変わる見た目は毎回作ります。図2.2-1 キャッシュするのは、絵ではなくデータ。実測。重いのは「ログを探す」ことだけです。覚えるのはその結果のデータにして、時刻で変わる見た目は毎回作ります。

原因は、覚えていたものが「絵」だったことです。点滅は、描画のたびに、そのときの秒に合わせて赤い帯か通常の色かを選んで作ります。残り時間も、描画のたびに「リセットの時刻 − 今の時刻」を計算して作ります。つまり、時刻で変わる見た目は、描画のたびに作り直さなければならないのに、できあがった絵を60秒貼りっぱなしにしたせいで、その作り直しが止まっていたのです。

直し方は、覚えるものを変えることでした。重いのは「ログのファイルを探して、中から値を取り出す」ことだけです。そこで、取り出した値(使用率とリセットの時刻)だけを60秒覚え、色と残り時間と点滅は、描画のたびに計算し直すようにしました。

Pythonstatusline.py(Codex のログを読むところ・冒頭)
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 を呼び、また「無い」と言われ、その次の描画でも……と、無いものを探し続けることになります。

図2.3-1 「PRはありません」も、覚えておく
図2.3-1 「PRはありません」も、覚えておく。説明例(計算)。「見つからなかった」という結果も、答えの1つとして覚えます。覚えないと、無いものを探し続けます。図2.3-1 「PRはありません」も、覚えておく。説明例(計算)。「見つからなかった」という結果も、答えの1つとして覚えます。覚えないと、無いものを探し続けます。

どれくらい無駄になるかを、ざっと計算してみます。描画が300ミリ秒ごとに休まず続いたとすると、5分(300秒)のあいだに 300 ÷ 0.3 = 1,000回、gh を起動することになります。実際には会話が止まっている時間もあるので、ここまで多くはなりませんが、それでも「無い」を確かめるためだけに GitHub へ何百回も問い合わせるのは、明らかに無駄です。

そこで、「無い」という結果も、答えの1つとして300秒覚えることにしました。このやり方は否定結果のキャッシュと呼ばれます。コードで見ると、ほんの少しの違いです。

Pythonstatusline.py(PR のチップ・例外処理を一部省いた抜粋)
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 のときは、さらに点滅させます。

Pythonstatusline.py(障害情報のチップ・例外処理を省いた抜粋)
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 検出を扱った記事から学びました。

Pythonstatusline.py(セッションごとの記録と 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) + 1

compact の見つけ方は、使っているトークンの数の急な減り方です。直前の記録が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つずつ書き出してみると、それぞれのちょうどよい時間が見えてくるはずです。

COMMENTS
コメント…

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

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 10 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。