【ai-usage-menuber-fork】
新しく値を取っても3秒後に
古い値へ戻った不具合とその理由

  • 前回作り替えたメニューバーアプリを、0.1.3から0.1.7まで更新しました。CodexとClaudeで別々だった2つのアプリを、1つのプロセスにまとめています。
  • 5秒ごとに約85MiBのログを全行読み直していたのが、CPUを食っていた原因でした。末尾の追記ぶんだけを読むように変えています。
  • Claudeの利用率APIが401を返すようになったので、Claude Code自身に /usage を聞きにいく迂回路を足しました。
  • 一番てこずったのが、新しく取った値が3秒後に2日前の値へ戻る不具合です。「新しさ」をリセット時刻の大小で測っていました。

関連記事

1. 2つのアプリは、1つに入れても2つのままだった

1.1. 設定ウインドウが2つ開いていた

8月末に作り替えたメニューバーアプリを、その後も毎日使っています1。
ただ、make build と打つと出てくるのはCodex版とClaude版の2つの .app でした。
両方をログイン項目に入れて常駐させます2。

これが地味に面倒で、フォントを1段大きくしたいと思うと、設定ウインドウを2回開いて同じ操作を2回することになります。
片方だけ直して、もう片方を直し忘れる。
そういう日が何度かありました。

0.1.3で、単一バンドル・単一プロセスへ入れ直しました。
親のAppDelegateが通知、設定ウインドウ、画面構成の変更、終了処理を持ち、サービスごとのControllerがポップオーバー、タイマー、履歴を持ちます。
バンドルIDは変わりますが、設定と最新値と週間履歴の保存先は前と同じところを使い続けているので、旧アプリを終了してログイン項目から外すだけで、棒グラフの履歴もそのまま引き継がれます。

1.1. 設定ウインドウが2つ開いていた

1.2. 見せないと決めたサービスは、止める

プロセスは1つでも、メニューバーの項目は2つのままです3。
Codexしか触らない日はClaude側を消しておきたいので、サービスごとの表示設定を残しました。

ただ、項目を隠すだけだと裏でタイマーは回り続けます。
オフにしたサービスは、ステータス項目、タイマー、更新、履歴の記録、通知の判定まで止めるようにしました。
オンに戻すと、保存してある値でまず項目を作って、そのあと非同期に取りにいきます。

両方オフのまま起動すると、当然ながら画面には何も出ません。
そのときは設定の「表示項目」を前に出します。何も起きないアプリだと思われるので。

非同期まわりは、サービスごとに独立した直列キューを持たせました。
ポップオーバーは保存済みのスナップショットで先に開いて、開いてから更新を要求します4。
止めたあとや隠したあとに返ってきた結果は、そのまま捨てる。

2. 【負荷の改善】5秒ごとに85MiBを読み直していた

2.1. 読むだけだから軽い、と思っていた

前回の記事で、Codex側はローカルのログを読むだけだから通信もしないし軽い、という書き方をしました5。
実際には、そちらのほうが重かったです。

アクティビティモニタで見ると、CPU使用率が常時それなりに出ていました。
原因は、5秒ごとに最新12件のJSONL(JSON Lines:1行1JSONで書かれたログ)を、毎回全行パースし直していたことです6。
実環境で約85MiBありました7。5秒ごとに85MiBを読んで捨てて、また読む。太りすぎです。

ファイル末尾を最初に一度だけ取って、あとは追記された部分だけを読むインデックスに変えました。
更新日時とサイズが変わっていなければ、ファイル本文には触りません。
途中で書き換わった行や、ファイルが置き換わった場合、切り詰められた場合も見るようにしています。

週間履歴のバックフィルも、起動のたびに同じ期間を全部走査していました。
保存してある最新の観測日時から再開すれば済む話でした。
直したあと、5秒の更新をまたいで計測してCPUは0.0%から0.8%の間です。

2.2. 減らしたぶん、必要なときには増やす

最新値の照合は、変化中なら10分、直近2回が同じ値なら60分の間隔にしてあります。
使っていない時間帯に問い合わせを繰り返さないための作りですが、これには穴がありました。
5時間枠のリセットは、こちらが安定していようがお構いなしに来ます8。

そこで、保存済みスナップショットの取得時刻と、分かっているリセット日時を突き合わせて、境界をまたいでいたら安定間隔を無視して取りにいくようにしました。
ポップオーバーを開いたときも同じで、安定中でも前回から10分経っていれば照合します。
開いた瞬間に古い数字を見せたくないので、ここだけは短いほうの間隔で判定します。

0.1.5では、5時間枠のカードをバーから折れ線に変えました。
リセット時刻から逆算した開始点を100%として左端に置き、そこから実測値をつなぎます。
残りの量だけでなく、この5時間をどう使ってきたかが1枚で分かります。

ついでに、「更新」を押した結果が5秒タイマーに破棄される競合も直しました。
タイマーの要求は実行中の取得を無効にせず、手動の要求は実行中の取得へ合流します。
ボタンには「更新中…」「更新済み」「更新失敗」が出ます。

3. 【データ取得】401が返ってきたときだけ、CLIに聞く

3.1. Claude Desktopを挟むと、ログイン情報がずれる

Claude側は、Claude Code自身のログイン情報を読んで利用率APIに問い合わせています9。
しばらく問題なく動いていたのですが、9月に入ってから401が続くようになりました10。
Claude DesktopからClaude Codeを使った後、キーチェーンに置かれた認証情報とアプリが握っている情報がずれるようです11。

自分でトークンを取り直す道もありますが、認証まわりに深入りするとキリがありません。
Claude Codeに聞けばいい。
claude -p /usage --safe-mode --no-session-persistence --output-format json を15秒上限で実行して、返ってきたJSONからセッション枠と週間枠を読みます12。

呼ぶ条件はかなり絞りました。
認証情報が見つからないときと、APIが401を返したときだけ。
429のときは呼びません13。多すぎると言われている相手に、別の口から叩きにいっても仕方がないので、Retry-After の時刻まで自動更新も手動更新も止めます14。

CLIの出力は、セッション枠と週間枠の両方を解析できたときだけ採用します。
エラー表示や「最後に分かっている値」の表示は拒否する。
成功したら次は60分待ち、失敗したら30分、1時間、2時間と延ばして最大4時間です。

3.2. 待っていたら2時間12分止まった

0.1.6で、設定の「詳細」タブにアプリ内のデバッグログを入れてありました。
記録するのは、どちらのサービスか、更新の開始と成功と失敗、強制更新かどうか、所要時間、エラーのドメインとコードだけです。
利用率も、会話の中身も、認証情報も、データの置き場所も書きません。

そのログを見て気づいたのが、CLIのフォールバックが成功してから、更新全体が成功したことになるまで2時間12分空いている回でした。
値は取れているのに表示が動かない。
CLI成功のあとに同期的に待っていたAPIの再試行が、Macのスリープか通信の停滞をまたいで、取得全体を占有していたのが原因です。

CLIから値を読めた時点で、そこで確定して表示に返すようにしました。
キーチェーンを読み直してのAPI再試行は、表示を待たせないバックグラウンドの確認へ回します。
直接APIを叩くほうにも、リクエスト10秒、リソース15秒の上限をつけました15。

4. 【キャッシュ管理】新しく取った値が、3秒後に古い値へ戻った

4.1. リセット時刻の大小で、新しさを測っていた

9月7日の朝、修正版を動かしていて妙な場面に出くわしました。
更新前のポップオーバーは、週間50%、最終更新は9月5日の22時46分、取得元はstatusLineのキャッシュです16。

そこでAPIが通って、週間61%に変わりました。
最終更新は7時16分、取得元はClaude API。
5時間セッションのほうは「リセット時刻を取得できません」で、0%と出ています17。

その約3秒後、ポップオーバーを開き直したら週間50%に戻っていました。
最終更新は9月5日22時46分。取得元はstatusLine。
1枚目とまったく同じ画面です。

statusLineは補助値という扱いなので、APIやCLIの値があるときは上書きしない作りにしてありました。
ただ、例外を1つ置いていました。
statusLineの持つリセット時刻が保存済みのものより先を指していたら、新しい周期に入ったとみなして、低優先度のデータをまとめて採用する。

このときAPIは5時間枠のリセット時刻を返さず、値は0でした。
一方、古いstatusLineには9月6日0時という、とっくに過ぎたリセット時刻が残っています。
0と9月6日を比べれば、9月6日のほうが大きい。それだけで「進んだ周期」に見えていました。

4.2. 採用の条件を2つにした

周期の更新として認めるのは、statusLineのキャッシュ自体の更新日時が保存済みのAPIやCLIの値より新しく、かつ示しているリセット時刻が現在より未来にある場合だけにしました。
片方でも欠けたら拒否します。
期限切れの時刻は、どれだけ大きくても新しい周期の証拠になりません。

拒否した値は、表示を上書きしないだけでなく、安定しているかどうかの判定からも外します。
古い値と見比べて「変わっていない」と判断されると、そこから60分待つことになるからです。

APIのリセット時刻が0で返るケース、期限切れのリセット、正しく未来を指す新しい周期の3つを、Providerのテストへ足しました。
修正版では、開き直しても61%のままです。

4.2. 採用の条件を2つにした

取得元が1つしかなかったころは、値が古いかどうかだけを気にしていれば済んでいました。
API、CLI、statusLineと増やしたぶん、どれを信じるかを毎回決めることになります。
迂回路を足すのは半日で済んで、足したあとに順番を決めるほうに何日かかかりました。
次に401が出たら、まずログの並びを見に行きます。

  1. 元にしたのはGitHubで公開されているMITライセンスのアプリで、著作権表示とライセンス文を残せば改変も再配布も制限なく許可されます。 – The MIT License
  2. macOSのログイン項目は、ログイン時に自動で開くアプリやバックグラウンドで動く項目をまとめた設定で、システム設定の「一般」から追加と解除ができます。 – Manage login items and background tasks on Mac
  3. メニューバーに居座る項目はAppKitのNSStatusItemで、1つのアプリがいくつでも持てます。 – NSStatusItem | Apple Developer Documentation
  4. NSPopoverのtransientは、ポップオーバーの外側で操作があると自動で閉じる挙動で、メニューバーから一時的に出す表示に向いています。 – NSPopover.Behavior.transient | Apple Developer Documentation
  5. Codex CLIは会話の記録を ~/.codex/sessions の下へ1行1JSONの形で書き出していて、その中にトークン数を伝える token_count イベントが混ざっています。 – Reverse engineering Codex CLI rollout traces
  6. JSON Linesは、UTF-8の各行がそれぞれ独立した正しいJSONになっている形式で、ヘッダも囲みも持ちません。ファイル全体は1つのJSON文書にはなりません。 – JSON Lines
  7. Codexのセッション記録は放っておくとかなり大きくなり、ローカルの履歴が数GiBに達した報告もあります。 – Codex app-server can grow to 30-40 GB when local rollout history is huge
  8. Codexの利用枠は5時間の枠と週間の枠の2本立てで、上限はChatGPTのプランごとに変わります。 – Using Codex with your ChatGPT plan
  9. Claude Codeの認証情報は、macOSでは暗号化されたキーチェーンへ、LinuxやWindowsでは権限を絞った .credentials.json へ保存されます。 – Authentication – Claude Code Docs
  10. HTTPの401は、要求そのものは届いているが有効な認証情報が付いていないという返事で、認証情報はあるが権限が足りない403とは区別されます。 – 401 Unauthorized – HTTP | MDN
  11. ProとMaxの利用枠はClaudeとClaude Codeで共有され、どちらでの利用も同じ枠へ加算されるとAnthropicは説明しています。 – Use Claude Code with your Pro or Max plan
  12. Claude Codeは -p を付けると対話画面を出さずに1回だけ応答して終了し、--output-format json を足すと結果を1つのJSONで受け取れます。 – Run Claude Code programmatically – Claude Code Docs
  13. HTTPの429は、決められた時間あたりの要求が多すぎるという返事で、いわゆるレート制限にあたります。 – 429 Too Many Requests – HTTP | MDN
  14. Retry-After は、どれだけ待ってから再要求すればよいかをサーバーが伝えるヘッダで、秒数と日時のどちらの書き方も許されています。 – Retry-After header – HTTP | MDN
  15. URLSessionのリソース側の上限は既定で7日あり、通信が止まったまま待ち続けるのを避けるには自分で短く設定し直す必要があります。 – timeoutIntervalForResource | Apple Developer Documentation
  16. statusLineは ~/.claude/settings.json に登録する外部コマンドで、Claude Codeが更新のたびにJSONを標準入力へ流し込み、コマンドの標準出力をそのまま1行として表示します。 – Customize your status line
  17. Claude Codeの利用枠は5時間ごとに戻るセッションの枠と週間の枠があり、/usage でどちらの残りも確認できます。 – Models, usage, and limits in Claude Code