feat(plugins): the usage screen shows Claude Code's 5-hour and weekly limits
Summary
The Usage screen showed Codex's 5-hour and weekly limits, but none for Claude Code. Claude Code doesn't write its limits to any file the plugin can read.
The agents Shipyard runs are structured (claude -p, stream-json). Every turn they take emits a rate_limit_event, which berthd already logs and then dropped:
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1793124000,"rateLimitType":"five_hour",
"unifiedWindows":{"five_hour":{"utilization":0.84,"resetsAt":1793124000,"status":"allowed"},
"seven_day":{"utilization":0.29,"resetsAt":1793638800,"status":"allowed"}}}}
This PR has berthd record those readings itself, and the plugin shows them.
internal/box/claudedriver.go claudeConv.apply
+ case "rate_limit_event": queue recordLimits(at, rate_limit_info) // run by speak, outside c.mu, live only
internal/box/claudelimits.go (new)
+ account: the session's CLAUDE_CONFIG_DIR (claudeHomeFor) → "default" for ~/.claude, else the folder's name
+ writeClaudeLimits(~/.berth/limits/<account>.json):
+ windows: used_percentage = utilization × 100, resets_at in seconds (milliseconds are divided down)
+ skip when the file's "at" is as new or newer; keep the windows the event leaves out
+ a file that doesn't parse is written afresh; statefile.Write (atomic); one mutex across sessions
plugins/usage/box/usage.py
+ claude_limits(): for each known Claude account, read ~/.berth/limits/<account>.json
+ five_hour → primary (300 min), seven_day → secondary (10080 min), in Codex's Window shape
+ "limits": codex limits + claude limits
usage-view / home widget: same logic as before; "Codex reports the new window…" → the agent's own name
- Live only. When berthd restarts it reads each log back without acting on it (
live == false). Those readings are old, so berthd doesn't write them. - Same file a status line writes. The plugin README keeps the status-line snippet for people who run Claude Code on its own screen, where no
rate_limit_eventarrives. Both writers use one shape ({"at", "limits": {"five_hour": {"used_percentage", "resets_at"}, …}}, plusaccountanddirfrom berthd). The reader ignores unknown keys, and berthd reads a file with extra keys (a status line's, another tool's) and writes it afresh when it's broken. - Because the windows use Codex's shape, the agent tile, the per-login dedupe, the "as of …" age, "reset since" and the Home widget all work for Claude with no new UI code.
- Verify skill:
features/usage-limits.md,scenarios/usage-limits-live.mjsandscripts/claude-usage.sh. The last one reads/usageas ground truth.dev-box.shgainsVERIFY_BOX_HOME/VERIFY_CLAUDE_ACCOUNT, so a dev box can have a HOME of its own (it never writes the real~/.berth/limits) with a signed-in account linked in under a neutral name.drive.mjs'sshot(name, locator)can now capture just one element.
The open question from the first version
The first version left people to add a status-line snippet themselves, and asked whether berthd should take over the status line. It doesn't need to. Structured agents report their limits on every turn, so berthd records them with no setup. The snippet stays as an option only for sessions on Claude Code's own screen.
Follow-up (not in this PR)
berthd headless run steps (judges, internal/box/headless.go) also run claude -p, and they could record the same event. This PR covers the structured driver only.
Evidence
Real box, end to end (verify-berthd, scenario usage-limits-live, on dev-box.sh up with its own HOME; the test account linked in as acme-max):
- Settings → Plugins → turned on Usage & accounts. In its Accounts tab, chose Use acme-max for… → Everywhere on devq.
- Ran one structured Haiku turn ("Reply with just: ok"). The box wrote
~/.berth/limits/acme-max.json: 5-hour 51%, weekly 23%,at2026-10-28T16:09:30.364Z. - Claude Code's own
/usagefor the same account, read seconds later (claude-usage.sh): 5-hour 51%, weekly 23%. - After Refresh, the Usage screen's meters show
5-hour limit= 51 andWeekly limit= 23. No console errors.

On an account other agents were using at the same time, the file and /usage agreed or differed by one point (/usage once showed 23% weekly when the event said 0.22). The scenario allows that one point; the screen has to match the file exactly.
Go: internal/box/claudedriver_test.go drives the stand-in Claude through a served box with a temp HOME:
- a live event writes the file (84% / 29%,
resets_atin seconds); - a one-window event keeps the other window, and a reset given in milliseconds ends up in seconds;
- after the conversation is forgotten and its log read back, nothing is written.
TestPlanLimitsKeepTheLatestReading covers four cases: an older reading, an empty one, another writer's file with extra keys, and a broken file. TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt covers the naming. With the new case removed, or the log read back as live, the end-to-end test fails.
Checks, run locally:
go vet ./...passes.go test -race ./...passes, exceptTestTheCatalogIsCapped, which counts the commands on the machine it runs on and fails onmainthere too.cargo checkpasses (on macOS, on this branch's tree).pnpm typecheck:plugins,check:titles,check:type,check:grid,check:csp,check:themesandpnpm buildpass.pnpm testpasses (618, plus 24 plugin tests).npx tsc -p e2eand thedocs-sitebuild pass.
Ask
Could a maintainer add the run-ci label? I can't add labels on this repo.
🤖 Generated with Claude Code