我剛剛花了整整四十分鐘,幫手邊的程式碼進行大翻修,正當我思緒正順、準備看它幫我把最後一塊拼圖拼上時,我的 AI 寫程式助手 Claude 突然無預警罷工了——它淡淡地彈出一個通知,說我這五小時的「扣打(使用額度)」已經全部用光。

就像是你去修車,引擎都已經拆得七零八落了,修車工具卻突然被一把沒收。然後順便吐嘈你一下您是要升級專案呢?還是等待 5 小時重置時間的到來?當時整個軟體還處於跑不動的半殘狀態,我的零件拆到一半卡在半空中,而 Claude 就這樣兩手一攤,直接裝死。
最讓我崩潰的不是它有限制,而是它「快沒電了」卻完全不會主動提醒!雖然你確實可以自己登入 Claude 的官方網頁去查剩餘用量,但這方式實在太不 AI 了。你想想,當你思緒正在瘋狂飆速、全心解決問題的時候,誰會有那個美國時間特地中斷工作、使用指令或打開網頁,還要傻傻地狂按 F5 重新整理,只為了盯著那個額度數字看?
結果就是,因為沒有常駐在眼前的進度條,也沒有像手機電池那樣的百分比可以看,這套工具總是在你靈感最流暢、最不想被打斷的節骨眼,毫無防備地讓你等待五小時的到來。
所以我寫了 usage。它只做一件事(喔!他現在已經可以做很多事了):把 Claude Code、Codex、Antigravity 的剩餘配額,像電池百分比那樣釘在 macOS 選單列的角落(右上角),Windows 則放在系統匣(右下角時鐘日期旁邊叫系統匣?)。不用下指令,不用開網頁。
brew install --cask aqua5230/usage/usage

下面講五個我真的翻車的地方,它們有個共同點:每一個都像前女友在翻舊帳,拿著你以為早就解決的問題,出來提醒你問題根本沒解決。
同類工具多半走這條路:串接官方的用量 API,發送請求定時去問,定時去問「我用了多少」。
我不想這樣做,理由很實際。查詢用量這件事本身不該花錢。如果看一次額度就要打一次 API,那我看得越勤,額度掉得越快,這在邏輯上就講不通。一個幫你盯著額度的工具,不該同時是消耗額度的來源,(解決問題的同時不該同時增加問題),而且我在用claude的時候它額度很貴,如果光是看額度就燒掉一堆 token ? 這不符合我們台灣人刻苦耐勞(喔~不,是勤儉持家!)的精神!
所以第一天就定死一條原則:不呼叫任何 LLM API。看配額這件事,永遠不會消耗你的 token。
注意這條原則講的是 LLM API,不是「完全不連網」。這個分寸後來被現實逼著校準了好幾次,第七個坑就是在講它。
先把地圖攤開,後面講坑的時候比較好對位置。
NSStatusItem 做選單列、WKWebView 做彈出面板pystray 做系統匣py2app,Windows 用 pyinstaller
這架構裡最要命、但也最保命的決定,就是畫了一條楚河漢界:負責算數學的資料層,絕對不准去碰畫介面的 GUI。
就是把「大腦」跟「臉」分開。
menubar.py 是 Mac 專用的臉(走 PyObjC),wintray.py 是 Windows 專用的臉(走 pystray)。這兩張臉一套講 Objective-C 的規矩,一套講Win32 的規矩,彼此根本無法溝通,但沒關係,因為它們背後去讀檔、解析、算 Token 的「大腦」是同一顆。
正因為當初切了這條線,後來我想生出 Windows 版的時候,我完全不需要把整個 App 打掉重練。底層算數學的邏輯原封不動,我只要專心搞定wintray.py 這層視窗就好。整個 Windows 移植工作,就從「重寫一個 App」瞬間縮小成「重寫一層視窗」。
原本我以為「讀本機檔案」是一件事。做下去才發現是兩件(而且是那種你以為是雙胞胎、結果連血型都不一樣的兩件)。
這邊算我運氣好。Claude Code 有狀態列 hook(掛鉤),這東西就像是你在別人的程式裡預留一個插座,事情發生時,對方會主動來呼叫你。
差別在方向。沒有 hook,你得自己一直去敲門問「現在幾 % 了?」。有 hook,對方有變動時就會主動通知你。
Claude Code 每次更新狀態列,就會去執行你註冊的那支腳本,把當前用量從 stdin(標準輸入,程式的入口管道)直接倒給你。
所以 usage 就裝一支這種腳本,它只做一件事:把收到的用量寫成一個 JSON 檔。app 完全不用管 Claude Code 內部怎麼運作,只要盯著那個檔案看就好。
但這個寫檔的動作有個致命的時間差。hook 在寫檔,app 在讀檔,兩支程式各跑各的,誰也不知道對方此刻在幹嘛。
如果 hook 直接打開正式檔覆蓋,寫到一半、app 剛好跑來讀,那它就會讀到半截殘破的 JSON,解析失敗,然後畫面閃一下空白給你看。
解法叫原子寫入。原子(atomic)的意思是不可分割,要嘛全發生,要嘛完全沒發生。
我分三步來做。第一步,用 mkstemp 在同一個資料夾開一個暫存檔。第二步,把完整內容寫進那個暫存檔。第三步,用 os.replace 把暫存檔改名、蓋掉正式檔。
第一步強調在同一個資料夾,是因為只有在同一個檔案系統上,第三步才是作業系統保證的原子操作。
這樣一來,讀檔的 app 就只會看到完整的舊版,或是完整的新版。中間那個尷尬的半成品根本不存在。就像別在客人桌上現組三明治,去廚房組好整盤,再一次把舊盤子換掉。
麻煩的是歷史包袱。這個檔案的路徑跟格式我換過好幾次,而使用者是不會跟著你升級的。他哪天裝的版本,就一直用那個版本。
也就是說,你機器上早就不存在的舊路徑,在別人機器上還好端端活著。因此讀取邏輯不能只認一個路徑,要由新到舊排一串降級鏈,依序試、讀到就停:
STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
LEGACY_STATUS_FILE = os.path.expanduser("~/.claude/usag-status.json")
TT_STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
CLAUDE_JSON_FILE = os.path.expanduser("~/.claude.json")
這四層裡,第 1 層是現在的正式檔。第 2、3 層是我以前取過的舊名字。
第 4 層 ~/.claude.json 是保底,撈的是 Claude Code 自己順手快取的 cachedUsageUtilization,這數字不保證新鮮,可能有點餿。但我寧可給一個可能有點餿的數字,也不要開天窗給使用者看空白。
處理這串舊檔,有一條我給自己立的鐵則:舊路徑只准讀,不准寫回去。
舊檔本來就有一個寫入者,也就是使用者機器上那個沒升級的舊版 app,它的 hook 還活著,還在寫那個舊路徑。
新版一旦順手寫回舊路徑,那個檔案就多了第二個寫入者,而且兩邊格式還不一樣。
想像一個超常見的具體場景:使用者同時裝了新舊兩版(一個 Homebrew、一個手動下載)。兩個寫入者就會互相蓋掉對方,誰後寫誰贏。
於是讀出來的數字就在兩種格式之間跳,像在玩大老二。使用者不會跟你說「格式衝突」,他只會說「怪怪的」,你就只能通靈了。
Codex 也有 hook 機制,usage 也在用(裝在 ~/.codex/hooks.json,管的是開新對話時接續進度)。但很可惜,它沒有回報用量的 hook。想知道現在用了多少?自己去它留下的檔案裡挖吧:
~/.codex/sessions/ 跟 ~/.codex/archived_sessions/ 底下的 JSONL(一種每行都是獨立 JSON 資料的記錄檔),逐筆事件記錄~/.codex/state_5.sqlite,thread 對應表~/.codex/logs_2.sqlite,另一份紀錄,token 用量跟 rate limit(API 呼叫頻率限制)都從這裡撈用量得自己從 JSONL 一行一行慢慢累加。這部分還算單純,勞力活而已。
真正麻煩的是成本:JSONL 記了 token 數,卻沒有可靠地記下這些 token 到底是哪個模型燒掉的。
這有什麼關係?因為不同模型單價差很多。只要模型認錯人,成本就整個歪給你看。把便宜的算成貴的,帳單瞬間膨脹,嚇死人。
模型資訊躺在 state_5.sqlite 的 thread 表裡。
於是 codex_loader.py 得做一件有點危險的事:用唯讀模式,去打開一個「別的程式正在使用中」的 SQLite(一種把整個資料庫存在單一檔案裡的輕量資料庫)。
def _readonly_sqlite_uri(path: Path) -> str:
"""Return a read-only SQLite URI that also accepts Windows drive paths."""
return f"{path.resolve().as_uri()}?mode=ro"
with closing(sqlite3.connect(_readonly_sqlite_uri(STATE_DB), uri=True)) as conn:
rows = conn.execute(
"SELECT id, model, cwd FROM threads",
).fetchall()
mode=ro 這三個字不是我在裝有禮貌,那是在保命。
為什麼?因為那個資料庫是人家 Codex 的,它隨時可能正在寫入。
現在的 SQLite 多半跑在一種叫 WAL(Write-Ahead Logging,寫入先進日誌檔再合併回主檔)的模式。這就像是餐廳服務生接到點單,會先快速記在口袋裡的便條紙(日誌),有空時再謄進櫃台的大帳本(主檔)。
我們要是用預設的讀寫模式硬連進去,系統就會以為我們也是來記帳的服務生,於是被迫參與到它的鎖與日誌狀態裡,跟人家搶筆。
最壞的情況是,使用者的 Codex 寫入失敗、甚至整個資料庫毀損。你想想,一個「幫你看用量」的小工具,結果把使用者真正在賺錢的 AI 工具給搞壞了?這種事絕對不能發生。
所以,整條 Codex 的讀取解析路徑,從頭到尾一個字都沒有寫入。
順帶一提,as_uri() 那步也不是裝飾用的。Windows 路徑長得像 C:\Users\...,直接串成 URI 會炸,as_uri() 會乖乖處理成 file:///C:/Users/...。這是我移植到 Windows 才含淚發現的。
專案裡有兩組 Claude 解析器、兩組 Codex 解析器。四個檔案,職責看起來高度重疊,簡直是在挑戰工程師的強迫症。我兩次想把它們收成一組,兩次都在逐行比對之後默默放棄。
為什麼放棄?差別在資料形狀。
codex_loader.py 是每個 token 計數事件一筆。它算的是增量,因為選單列要的是即時的燃燒率跟時間序列。
adapters/codex.py 則是整個 session 一筆。它取的是最後的累計總量,因為報告要的是彙總。
增量跟總量,這是天生矛盾的需求。
如果硬要把兩組程式合併,總有一邊得妥協:它得在讀完資料之後,再依照對方的格式換算一次。也就是說,它的換算依據,完全綁死在另一邊的資料格式上。
這就像是兩家公司共用一套匯率表,其中一家私下把美金改回報成英鎊,卻沒通知另一家。
結果就是,只要有一邊偷偷改了格式,另一邊就會在「完全沒被改到」的情況下,默默算出錯的數字。程式不會拋例外,不會有紅字警告,就是安靜地、面帶微笑地給你一個錯的值(這才是最可怕的)。
所以最後我選擇讓它們各自活著。並且,我把「這兩組是刻意分開的、不要合併」寫成專案規範的一條,附上理由。這樣以後不管是我自己、別人、還是哪個手癢的 AI 想順手重構,都會先讀到這條。
在會誘惑人重構的地方留一條寫明理由的禁令,比留一個漂亮的抽象有用多了。
usage 會判斷你的燃燒率,用 tokens/min 分成 Idle、Normal、Active、Heavy 四級,選單列的文案跟表現都看這個等級在演。
第一版算法很直覺:區間內的 token 加總,除以經過的分鐘數。國小數學,能有什麼問題?
結果所有重度使用者都被永久標成 Heavy,而且死都降不下來。停手十分鐘去泡咖啡、發個呆,回來一看——還是 Heavy。它認定你在燒,你人不在也照燒。
兇手是 cache_read_tokens。
現在的 LLM API 都有 prompt caching(提示詞快取,能記住剛送過的上下文)。同一段 context 重複送出時,命中快取的部分計費極低。
而 AI coding 工具的工作模式,說穿了就是每一輪把整個對話 context 重送一次(是的,它每次都把整本書從頭念給模型聽)。
所以一段長對話,光 cache_read_tokens 就能累積到幾百萬,但那些 token 幾乎沒有真的消耗你的額度。
把它算進燃燒率,等於在跟使用者說「你重讀了自己的對話紀錄,所以你是重度使用者」。這判斷根本沒有意義,它反映的只是你的對話有多長而已。
修法是在 entry 這層多開一個出口,讓算燃燒率的人拿到的預設值就是對的:
@property
def active_tokens(self) -> int:
return self.input_tokens + self.output_tokens + self.cache_creation_tokens
cache_creation_tokens 留著,cache_read_tokens 不算。因為建立快取是真的在花錢,讀取才是便宜的那個。creation 跟 read,差一個字,意思完全相反。
(cache_read_tokens 本身沒有被消失,總量顯示還是要靠它。被換掉的只有燃燒率的分子。)
上層直接用 active_tokens:
active_tokens = sum(entry.active_tokens for entry in entries)
elapsed_seconds = (entries[-1].timestamp - entries[0].timestamp).total_seconds()
elapsed_minutes = max(elapsed_seconds / 60.0, 1.0)
burn_rate = active_tokens / min(elapsed_minutes, 60.0)
在這段算式裡,max(..., 1.0) 是第一道防線。它的基本作用是防除以零,但更重要的是,它能防極短區間把數字爆到天上去。
而 min(..., 60.0) 則是第二道防線。它的任務是不讓你長時間掛機把分母拉到無限大、把燃燒率壓成零。
為什麼要這樣限制?因為我們要看的是「最近一小時的節奏」,不是「開機以來的平均」。畢竟,那個開機以來的平均數字,對決策一點幫助都沒有。
門檻則是拿真實使用紀錄回頭校準的。舊值是 50/250/1000,用 72 小時的實測資料重算之後改成:
BURN_RATE_THRESH_NORMAL = 500.0 # tokens/min
BURN_RATE_THRESH_ACTIVE = 2500.0
BURN_RATE_THRESH_HEAVY = 6000.0
整整差了一個數量級。這條後來寫進專案的 CLAUDE.md,理由只有一句:不剔掉 cache_read 的話,重度使用者會被永遠鎖在 Heavy。
為了不要每次啟動都重掃幾百個 JSONL(那開機速度會慢到讓人想砸電腦),usage 會把解析結果寫成磁碟快取。而快取檔總有壞掉的一天:編碼壞了、JSON 被截斷了、內容根本不是預期的結構。
最直覺的處理是讀不出來就刪掉重算。反正只是快取嘛,刪了又不會怎樣。
我一開始就是這樣寫的。然後我就再也查不出快取到底為什麼會壞了。
因為證據被我自己的錯誤處理吃掉了。使用者說「偶爾會卡一下」,我請他把壞掉的檔案給我看看,他說沒有啊,資料夾裡很乾淨。當然乾淨啊!程式每次都幫他刪得一乾二淨,比掃地機器人還勤勞。
所以我加了一層隔離區。壞檔不直接刪,先搬到 ~/.usage/quarantine/,把命案現場保留下來。
但真正容易寫錯的是下一步。看這兩個分支的差別:
if not isinstance(payload, dict):
quarantine(path, "not-a-mapping")
path.unlink(missing_ok=True)
return None
if payload.get("schema_version") != schema_version:
path.unlink(missing_ok=True)
return None
兩個分支的結局一模一樣,都是「這個快取不能用、刪掉」。但只有第一個會先送進隔離區。
為什麼?因為 schema_version 對不上根本不算壞掉,那是正常的版本升級啊。每次我改了快取格式發一版,所有使用者的舊快取都會對不上——這是預期中的事,不是意外。
而隔離區是有預算的,只留最新的 10 個檔,每個上限 5MB。不然一個「診斷用」的機制會反過來把使用者的硬碟塞爆,那就本末倒置了。
假設當初我沒把這兩種情況分開、一律送隔離區,會發生什麼事?每一次版本升級,每個使用者的隔離區都會被一批「其實完全正常」的舊快取灌滿,10 個名額一次洗掉,真正壞掉的那個關鍵證物反而被擠出去。防禦機制親手毀掉它要保護的東西,完美的自殺式設計。
兩個分支長得幾乎一樣,處理方式卻必須完全相反。而且最陰險的是——寫錯的那個版本,不會有任何錯誤訊息告訴你它錯了。
menubar.py 是 macOS 那側的主控檔案,PyObjC 寫的。它會長大,這幾乎是宇宙定律。每加一個選單項、一個面板、一個通知,最順手的位置永遠是「啊就再加在這個檔案裡就好了嘛」。
它被我拆過兩次,兩次都長回來,最高峰到過 2485 行。像那種除完草隔兩週又長回來的院子。
第二次長回來之後我才願意承認:問題不在我不知道要拆,而在「檔案不要太大」這條規範,一直以來只靠我的自律在維持。而自律這種東西,在趕著出版本的時候,永遠是第一個被丟出車外的。
所以我寫了一支 CI 腳本,把規範從「道德勸說」變成「會讓 build 直接失敗」的硬性檢查:
"""Fail CI when a file guarded by a growth policy creeps past its ceiling.
`menubar.py` has been split apart twice and grown back both times, because the
policy in CLAUDE.md was enforced by nothing but good intentions. Lower a ceiling
whenever a cut lands; never raise one to make CI green.
"""
CEILINGS = {
"menubar.py": 1242,
}
規則只有一條:天花板只能降,不能升。每砍一刀、把一塊功能搬進獨立模組,就把天花板降到當下的行數。想加新功能又超過?那就先去拆,不然 CI 給你亮紅燈,你哪裡都去不了。
那句 enforced by nothing but good intentions(全靠一片好意在維持)是特地寫給未來的自己看的。因為我很清楚,半年後我一定會忘記為什麼有這個奇怪的檢查,然後想說「這限制好煩喔,改大一點好了」——不,你不可以。
前後十刀下來,2485 行變成現在的 1242 行,最近兩刀是從 1934 砍到 1582、再砍到 1242。
拆的過程有個 PyObjC 特有的陷阱值得記一下:selector 不能亂搬。
selector 是 Objective-C 用來指涉某個方法的名字,PyObjC 的按鈕跟選單項就是靠它綁定回呼(按鈕點擊後觸發的動作)的。
這就像是公司對外的聯絡窗口。你可以把背後實際做事的部門搬到別的樓層,但那個掛名的窗口必須留在原來的座位上接電話。
所以那個方法必須留在原本的 NSObject(Objective-C 的基礎類別)子類上。
你可以把計算邏輯搬去別的模組,但被 selector 指到的那個方法本身動不得。否則要等到執行期才會炸,而且錯誤訊息完全看不出跟搬移有關(你會找到懷疑人生)。
所以每一刀的形狀都一樣:selector 留在原地站崗,它呼叫的實作搬出去。
拆出來的葉模組裡有一整批是面板。同一份資料可以套上很不一樣的視覺:經典卡片、數字雨、復古報紙、午夜水族箱、黑洞。這件事做得到,完全是因為資料層跟顯示層在前面就拆乾淨了。面板只是 HTML,拿到的是同一包算好的數字。

回到第一節那條原則:不呼叫任何 LLM API。
Claude Code 跟 Codex 都做得到,因為它們的用量本來就乖乖寫在你的磁碟上,不連網也讀得到。我一度以為整個專案都可以維持這個漂亮的形狀。
然後我加了 Antigravity 支援,事情就不一樣了。
Antigravity 的配額不在你的磁碟上。它沒有把用量寫成本機檔案,唯一的來源是 Google 的配額端點。所以我只有兩條路:要嘛不支援,要嘛承認這一塊就是得連網。
最後的做法是:讀 Antigravity CLI 已經存在你機器上的那份登入憑證(macOS 在 Keychain、Windows 在認證管理員,唯讀,不改它),需要時換一張新的 access token,然後去問配額。老實說,這跟第一節那個「我才不要這樣做」的路,形狀確實有點像(打臉來得真快)。
那差別到底在哪?差別是它不是 LLM API。問配額不會消耗你的 token,這跟你自己在 Antigravity 裡打一次查詢指令是同一件事。原則沒有被打破,被打破的是我對「本機」這兩個字的想像。
所以現在專案的對外說法是分開講的:不呼叫任何 LLM API 是原則,Claude Code 跟 Codex 完全不需連網是事實,Antigravity 得走網路也是事實。SECURITY.md 裡直接把每一個對外連線的用途、端點跟時機列成一張表攤開來,讓人可以自己核對,而不是叫人相信我一句「我沒有連網啦」。
一句宣稱,只要有一個反例就整句崩掉。一張清單不會,它只會多一列而已。
回頭看,這五個坑其實是同一件事的五種樣子:我把兩個很像的東西,當成了同一件。讀本機檔案跟讀本機檔案不一樣,token 跟 token 不一樣,壞掉的快取跟過期的快取不一樣,拆好的檔案跟會長回來的檔案不一樣,連「本機」這兩個字自己都不一樣。
而每次修好,靠的從來就不是什麼更聰明的抽象。做法很土:在原本只有一條路的地方,老實加一個分支,然後把「為什麼要有這個分支」寫進註解、寫進規範、寫進 CI,讓下一個想把它合併掉的人(很可能就是三個月後的我)先讀到理由。
寫程式最難的不是想出通用解,是看出兩個長得一樣的東西其實不一樣。
GitHub:https://github.com/aqua5230/usage
官網:https://aqua5230.github.io/usage/
Issue 跟 PR 都歡迎。