From 87dad9cb5536e2efd9f33d3362a095cb2f1f2315 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Thu, 22 Oct 2026 15:16:25 +0000 Subject: [PATCH 1/4] feat(plugins): the usage screen shows claude code's 5-hour and weekly limits Claude Code passes its plan limits (rate_limits.five_hour and seven_day) only to a status line. usage.py now reads them from ~/.berth/limits/.json, which a status line writes, and reports them in Codex's window shape, so the Usage tile and the Home widget show them beside Codex's. The plugin README has the status line lines. Co-Authored-By: Claude Opus 5.5 --- app/src/lib/mock-usage.ts | 9 +++++++++ docs/changelog.mdx | 7 +++++++ docs/guides/home.mdx | 2 +- plugins/usage/README.md | 22 +++++++++++++++++++++- plugins/usage/box/usage.py | 30 +++++++++++++++++++++++++++++- plugins/usage/src/home.tsx | 2 +- plugins/usage/src/usage-view.tsx | 10 +++++----- 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/app/src/lib/mock-usage.ts b/app/src/lib/mock-usage.ts index e794a1556..68b356fef 100644 --- a/app/src/lib/mock-usage.ts +++ b/app/src/lib/mock-usage.ts @@ -149,6 +149,15 @@ function report(box: string) { secondary: { used_percent: 12, window_minutes: 10080, resets_at: Math.round(now.getTime() / 1000 + 4 * 86400) }, }, }, + { + agent: "claude", + account: "default", + at: at(gpu ? 3 : 0.02), + limits: { + primary: { used_percent: 18, window_minutes: 300, resets_at: Math.round(now.getTime() / 1000 + 2 * 3600 + 600) }, + secondary: { used_percent: 41, window_minutes: 10080, resets_at: Math.round(now.getTime() / 1000 + 3 * 86400 + 7200) }, + }, + }, ], }; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index bd1f405d4..5c7df4f56 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -111,6 +111,13 @@ again. turn.** Tool use by a Claude Code subagent left running after the agent's turn ended used to put the session back to running until your next prompt. +- **Claude Code's 5-hour and weekly limits on the Usage screen.** Claude + Code passes its plan limits only to a status line, so once your status + line saves them to `~/.berth/limits/.json`, the Usage screen + and the Home widget show them beside Codex's: how much is used, as of + when, and when each window resets. The plugin's README has the lines to + add to a status line. + - **Stopping several agents can archive their worktrees too.** The Agent Dashboard's Stop and Clean up dialogs have a box, **Archive their worktrees where it's safe**. Ticked, each stopped agent's worktree is diff --git a/docs/guides/home.mdx b/docs/guides/home.mdx index 7637db141..e512701a7 100644 --- a/docs/guides/home.mdx +++ b/docs/guides/home.mdx @@ -21,7 +21,7 @@ widgets, which you choose, move and size yourself. | **Pull requests** | Pull requests waiting on your review, then yours with their checks and review state | One GitHub search with `gh` on a box (the Pull request plugin) | | **CI failures** | Workflow runs whose latest result failed on your worktrees' branches, a click from their logs | `gh run list` in each GitHub project (the Pull request plugin) | | **Git activity** | Commits and lines changed a day, the last two weeks, across your projects | `git log` on each project's main checkout, every 15 minutes | -| **Agent usage** | Tokens today and this week, Claude Code's own cost estimate, and how much of a plan's window Codex last saw used | The [Usage & accounts](/guides/plugins) plugin, which is off until you turn it on | +| **Agent usage** | Tokens today and this week, Claude Code's own cost estimate, and how much of a plan's window Codex or Claude Code last saw used | The [Usage & accounts](/guides/plugins) plugin, which is off until you turn it on | | **Boxes** | Each box's CPU, memory and disk, the agents at work on it, and boxes that are offline | Stats the app already reads | | **Running services** | Dev servers listening in your worktrees; click one for a browser tab beside its agent | Services the app already lists | | **Notes** | A note or checklist that stays on Home (lines written `- [ ]` tick with a click) | Kept on this Mac (the Notes plugin) | diff --git a/plugins/usage/README.md b/plugins/usage/README.md index cea41a3fa..50c9f9333 100644 --- a/plugins/usage/README.md +++ b/plugins/usage/README.md @@ -26,10 +26,30 @@ The numbers come from the files each agent writes on the box. Nothing is estimat | --- | --- | --- | | Claude Code | `~/.claude/projects/**/*.jsonl` and each account's `/projects` | `message.usage` on each assistant message, deduplicated by message and request id. It also holds Claude Code's own per-session `totalCostUSD`. | | Codex | `~/.codex/sessions/**/*.jsonl` and each account's `/sessions` | `token_count` events (cumulative totals per session) and the `rate_limits` Codex last saw. | +| Claude Code limits | `~/.berth/limits/.json`, written by your status line | The `rate_limits` Claude Code last passed to the status line. | - **Input** excludes cached input. Cache reads and writes are shown separately; Codex's `input_tokens` includes its cached tokens, so the plugin subtracts them. - **Cost** is shown only for Claude Code, and only as Claude Code's own estimate at API list prices. On a subscription (Claude Max or Pro) the screen says the plan covers the use. Codex gets no dollar figure: it records none, and the plugin keeps no price table. -- **Plan limits** are shown only for Codex, which writes its 5-hour and weekly windows into its transcripts. They show as "as of" the last turn Codex took. A window that has reset since says so instead of showing an old percentage. Claude Code doesn't write its limits anywhere the plugin can read, so it shows none. +- **Plan limits** show the 5-hour and weekly windows, "as of" the last turn the agent took. A window that has reset since says so instead of showing an old percentage. Codex writes them into its transcripts. Claude Code passes them only to its [status line](https://code.claude.com/docs/en/statusline), so Claude Code's show once your status line records them (below). + +### Claude Code's plan limits + +Claude Code gives a status line its `rate_limits` (Pro and Max plans, after the session's first reply) and writes them nowhere else. To see them here, have your status line script save them, one file per account: + +```sh +in=$(cat) # the JSON Claude Code passes your status line +limits=$(printf '%s' "$in" | jq -c '.rate_limits // empty') +if [ -n "$limits" ]; then + dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude}; dir=${dir%/} + account=$(basename "$dir"); [ "$dir" = "$HOME/.claude" ] && account=default + mkdir -p ~/.berth/limits + jq -n --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson limits "$limits" '{at: $at, limits: $limits}' \ + > ~/.berth/limits/"$account".tmp && mv ~/.berth/limits/"$account".tmp ~/.berth/limits/"$account".json +fi +# …then print your status line from "$in" as before. +``` + +The account is `default` for `~/.claude` and the folder's name for `~/.berth/accounts/claude/`. A file for any other name is ignored. The screen shows the limits as of `at`, so a box whose status line hasn't run for a while shows an old reading, labelled with its age. ### How it runs on the box diff --git a/plugins/usage/box/usage.py b/plugins/usage/box/usage.py index 88272d250..7de9be5d8 100644 --- a/plugins/usage/box/usage.py +++ b/plugins/usage/box/usage.py @@ -25,6 +25,7 @@ HOME = pathlib.Path.home() CACHE = HOME / ".cache" / "berth-usage" / "files.json" ACCOUNTS = HOME / ".berth" / "accounts" +LIMITS = HOME / ".berth" / "limits" BUDGET_S = float(os.environ.get("BERTH_USAGE_BUDGET", "45")) MAX_OUT = 60_000 NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$") @@ -231,6 +232,33 @@ def codex_file(path): return s +# Claude Code's windows in Codex's shape: five_hour is the primary, seven_day +# the secondary. +CLAUDE_WINDOWS = (("five_hour", "primary", 300), ("seven_day", "secondary", 7 * 24 * 60)) + + +def claude_limits(): + """The plan limits Claude Code last passed to a status line, from + ~/.berth/limits/.json: {"at": ISO time, "limits": the status + line's rate_limits}. Claude Code writes them nowhere else, so a status + line has to record them (see the README); without the file there are + none.""" + out = [] + for name, _ in claude_dirs(): + o = read_json(LIMITS / f"{name}.json") + if not isinstance(o, dict) or not isinstance(o.get("limits"), dict): + continue + windows = {} + for key, slot, minutes in CLAUDE_WINDOWS: + w = o["limits"].get(key) + if isinstance(w, dict) and isinstance(w.get("used_percentage"), (int, float)) and isinstance(w.get("resets_at"), (int, float)): + windows[slot] = {"used_percent": w["used_percentage"], "window_minutes": minutes, "resets_at": int(w["resets_at"])} + at = iso(o.get("at") or "") + if windows and at: + out.append({"agent": "claude", "account": name, "at": at, "limits": windows}) + return out + + def report(days): started = time.monotonic() CACHE.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -316,7 +344,7 @@ def report(days): "columns": ["input", "output", "cache_read", "cache_write"], "daily": [[*k, *v] for k, v in sorted(daily.items())], "sessions": sorted(sessions.values(), key=lambda r: r["last"] or "", reverse=True), - "limits": list(limits.values()), + "limits": list(limits.values()) + claude_limits(), } # Fit exec's output: drop the oldest sessions until it does. text = json.dumps(out, separators=(",", ":")) diff --git a/plugins/usage/src/home.tsx b/plugins/usage/src/home.tsx index 10eb6199f..5ba5c978f 100644 --- a/plugins/usage/src/home.tsx +++ b/plugins/usage/src/home.tsx @@ -6,7 +6,7 @@ import { AGENT_NAME, compact, days, usd } from "./data"; // The Usage widget on Home: tokens your agents used today and each day of // the week, what Claude Code estimates they cost, and how much of a plan's -// window Codex last saw used. usage.py reads the agents' own records on +// window Codex or Claude Code last saw used. usage.py reads the agents' own records on // each box, every 5 minutes while the widget is on screen. export interface UsageSummary { diff --git a/plugins/usage/src/usage-view.tsx b/plugins/usage/src/usage-view.tsx index 22b0f8d2a..b777e00c7 100644 --- a/plugins/usage/src/usage-view.tsx +++ b/plugins/usage/src/usage-view.tsx @@ -271,8 +271,8 @@ export interface SharedLimits { limits: Limits; } -// dedupeLimits keeps the newest limits Codex saw per login. -// Limits Codex saw before the period are left out, as its tokens are. +// dedupeLimits keeps the newest limits the agent saw per login. +// Limits it saw before the period are left out, as its tokens are. function dedupeLimits(agent: Agent, sources: Source[], accounts: Record, period: Period): SharedLimits[] { const out = new Map(); for (const { box, report } of sources) { @@ -390,8 +390,8 @@ function windowName(w: Window) { return h >= 24 * 6 ? "Weekly limit" : h >= 20 ? "Daily limit" : `${h}-hour limit`; } -// LimitRows shows the plan windows Codex itself last reported, and says how -// old that report is: it only knows what Codex saw on its last turn. +// LimitRows shows the plan windows the agent itself last reported, and says +// how old that report is: it only knows what the agent saw on its last turn. function LimitRows({ shared }: { shared: SharedLimits }) { const l = shared.limits; const windows = [l.limits.primary, l.limits.secondary].filter(Boolean) as Window[]; @@ -413,7 +413,7 @@ function LimitRows({ shared }: { shared: SharedLimits }) {
= 90 ? "bg-destructive" : w.used_percent >= 70 ? "bg-warning" : "bg-primary/70")} style={{ width: `${Math.min(100, w.used_percent)}%` }} />
)} -
{past ? `It reset ${reset.toLocaleDateString()}; Codex reports the new window on its next turn.` : `Resets ${reset.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}`}
+
{past ? `It reset ${reset.toLocaleDateString()}; ${AGENT_NAME[l.agent]} reports the new window on its next turn.` : `Resets ${reset.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}`}
); })} From a186ba566c21c6aac472f6510ae031fa4689dbfd Mon Sep 17 00:00:00 2001 From: Stener1 Date: Wed, 28 Oct 2026 15:50:31 +0000 Subject: [PATCH 2/4] feat(box): the usage screen shows claude code's limits from structured agents A structured Claude Code agent (claude -p) runs no status line, so the limits file the Usage plugin reads stayed empty on a box whose agents all run that way. Each of its turns says the account's plan limits in a rate_limit_event; berthd now writes them to ~/.berth/limits/.json as they come, keeping the newest reading and any window a turn leaves out. A log read back after a restart writes nothing. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.mdx | 15 +-- internal/box/claudedriver.go | 13 ++ internal/box/claudedriver_test.go | 189 +++++++++++++++++++++++++++++- internal/box/claudelimits.go | 136 +++++++++++++++++++++ plugins/usage/README.md | 10 +- plugins/usage/box/usage.py | 10 +- 6 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 internal/box/claudelimits.go diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 5c7df4f56..a48c39cbd 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -46,6 +46,14 @@ again. branch, and asks git again only when the repository changes (or after a minute). +- **Claude Code's 5-hour and weekly limits on the Usage screen.** Each + turn of a structured Claude Code agent says how much of its account's + plan limits is used, and the box now keeps that in + `~/.berth/limits/.json`. The Usage screen and the Home widget + show it beside Codex's: how much is used, as of when, and when each + window resets. An agent in Claude Code's own terminal screen can add + its readings from a status line; the plugin's README has the lines. + ## v0.3.31 - **Worktrees with submodules can be removed.** git refuses to remove any @@ -111,13 +119,6 @@ again. turn.** Tool use by a Claude Code subagent left running after the agent's turn ended used to put the session back to running until your next prompt. -- **Claude Code's 5-hour and weekly limits on the Usage screen.** Claude - Code passes its plan limits only to a status line, so once your status - line saves them to `~/.berth/limits/.json`, the Usage screen - and the Home widget show them beside Codex's: how much is used, as of - when, and when each window resets. The plugin's README has the lines to - add to a status line. - - **Stopping several agents can archive their worktrees too.** The Agent Dashboard's Stop and Clean up dialogs have a box, **Archive their worktrees where it's safe**. Ticked, each stopped agent's worktree is diff --git a/internal/box/claudedriver.go b/internal/box/claudedriver.go index 32218b795..76f334353 100644 --- a/internal/box/claudedriver.go +++ b/internal/box/claudedriver.go @@ -232,6 +232,10 @@ type claudeConv struct { queue []func() kick chan struct{} gone bool + // account names the Claude Code account the session runs on, and + // accountDir its folder, once its plan limits are first recorded. + // Only speak uses them (recordLimits). + account, accountDir string } // claudeDriver holds the structured sessions berthd follows. @@ -388,6 +392,7 @@ type streamLine struct { Error string `json:"error"` Response json.RawMessage `json:"response"` } `json:"response"` + RateLimit json.RawMessage `json:"rate_limit_info"` } // apply reads one log entry into what the driver knows; live, it also says @@ -518,6 +523,14 @@ func (c *claudeConv) apply(e agenthost.Entry, live bool) { say = append(say, func() { c.event(adapters.Waiting, map[string]any{"reason": askReason(r)}, map[string]any{adapters.AskKey: ask}) }) + case "rate_limit_event": + // How much of the account's plan limits is used, kept where the + // Usage plugin reads it. Said only live, as all of this: a log read + // back after a restart holds old readings. + if len(l.RateLimit) > 0 { + info, at := l.RateLimit, time.UnixMilli(e.At) + say = append(say, func() { c.recordLimits(at, info) }) + } case "control_cancel_request": // Withdrawn: a helper that asked was stopped, or the turn was. if c.drop(l.RequestID) && live { diff --git a/internal/box/claudedriver_test.go b/internal/box/claudedriver_test.go index 8c325f46f..5b46e7c29 100644 --- a/internal/box/claudedriver_test.go +++ b/internal/box/claudedriver_test.go @@ -197,6 +197,11 @@ for line in sys.stdin: out({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": t, "content": "Your questions have been answered: \"Tea or coffee?\"=\"%s\"." % a}]}, "parent_tool_use_id": None, "uuid": uid()}) say("You chose " + a + ".") result() + elif text.startswith("limits "): + # The account's plan limits, as Claude Code says them each turn. + out({"type": "rate_limit_event", "rate_limit_info": json.loads(text[len("limits "):]), "uuid": uid()}) + say("Noted.") + result() elif text == "work": out({"type": "stream_event", "event": {"type": "message_start"}, "parent_tool_use_id": None}) out({"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Working through it"}}, "parent_tool_use_id": None}) @@ -672,7 +677,9 @@ func TestAStructuredSessionNeverPromptedGoesToItsTerminalAndBack(t *testing.T) { until("the move to its terminal", func() bool { return call(t, c, "POST", "/v1/sessions/"+name+"/switch", "", map[string]string{"to": "terminal"}, &moved) == 200 }) - until("Claude Code's own screen", func() bool { return strings.Contains(screen(), "Claude Code, new 00000000-0000-4000-8000-0000000000c1") }) + until("Claude Code's own screen", func() bool { + return strings.Contains(screen(), "Claude Code, new 00000000-0000-4000-8000-0000000000c1") + }) if s := screen(); strings.Contains(s, "No conversation found") { t.Fatalf("the terminal couldn't open the conversation:\n%s", s) } @@ -978,3 +985,183 @@ func TestATurnClaudeStartsByItselfShowsAsWorking(t *testing.T) { } } } + +// Claude Code says each turn how much of its account's plan limits is used; +// berthd keeps it where the Usage plugin reads it, in a temp HOME here. +func TestAStructuredSessionRecordsItsAccountsPlanLimits(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("the stand-in Claude needs python3") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "claude"), []byte(fakeStreamClaude), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + account := filepath.Join(home, ".berth", "accounts", "claude", "acme-max") + os.MkdirAll(account, 0o700) + envFile := filepath.Join(t.TempDir(), "env.json") + if err := saveBoxEnv(envFile, BoxEnv{Env: map[string]string{"CLAUDE_CONFIG_DIR": account + "/"}}); err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + var box *Box + c, _ := servedBox(t, func(b *Box) { + b.EnvFile = envFile + b.ConversationsDir = conversationsDir(t) + b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe} + box = b + }) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": gitRepo(t)}, nil) + + file := filepath.Join(home, ".berth", "limits", "acme-max.json") + read := func(ok func(limitsFile) bool) limitsFile { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for { + var f limitsFile + raw, err := os.ReadFile(file) + if err == nil && json.Unmarshal(raw, &f) == nil && ok(f) { + return f + } + if time.Now().After(deadline) { + t.Fatalf("the limits never showed it: %s (%v)", raw, err) + } + time.Sleep(50 * time.Millisecond) + } + } + window := func(f limitsFile, name string) limitWindow { + var w limitWindow + json.Unmarshal(f.Limits[name], &w) + return w + } + + var sess Session + prompt := `limits {"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"}}}` + if status := call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "claude", "driver": "claude", "prompt": prompt}, &sess); status != 200 { + t.Fatalf("start: %d", status) + } + t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) }) + f := read(func(f limitsFile) bool { return len(f.Limits) == 2 }) + if f.Account != "acme-max" || f.Dir != account || f.At == "" { + t.Fatalf("file = %+v", f) + } + if w := window(f, "five_hour"); w != (limitWindow{UsedPercentage: 84, ResetsAt: 1793124000}) { + t.Fatalf("five_hour = %+v", w) + } + if w := window(f, "seven_day"); w != (limitWindow{UsedPercentage: 29, ResetsAt: 1793638800}) { + t.Fatalf("seven_day = %+v", w) + } + + // An event with one window keeps the other; a reset given in + // milliseconds is kept in seconds. + call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": `limits {"unifiedWindows":{"seven_day":{"utilization":0.31,"resetsAt":1793638800000}}}`, "when": "now"}, nil) + f = read(func(f limitsFile) bool { return window(f, "seven_day").UsedPercentage == 31 }) + if w := window(f, "seven_day"); w.ResetsAt != 1793638800 { + t.Fatalf("seven_day = %+v", w) + } + if w := window(f, "five_hour"); w.UsedPercentage != 84 { + t.Fatalf("five_hour went: %s", f.Limits["five_hour"]) + } + + // Read back from its log (berthd restarted), the conversation says + // nothing it said before: the readings in it are old. + os.Remove(file) + box.claude().forget(sess.Name) + var res transcript.Result + call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res) + box.claude().mu.Lock() + conv := box.claude().convs[sess.Name] + box.claude().mu.Unlock() + if conv == nil { + t.Fatal("the conversation isn't followed again") + } + select { + case <-conv.caught: + case <-time.After(15 * time.Second): + t.Fatal("the log was never read back") + } + time.Sleep(500 * time.Millisecond) + if raw, err := os.ReadFile(file); err == nil { + t.Fatalf("a replayed reading was written: %s", raw) + } +} + +func TestPlanLimitsKeepTheLatestReading(t *testing.T) { + path := filepath.Join(t.TempDir(), "limits", "acme.json") + at := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) + both := json.RawMessage(`{"unifiedWindows":{"five_hour":{"utilization":0.5,"resetsAt":1793124000},"seven_day":{"utilization":0.2,"resetsAt":1793638800}}}`) + got := func() limitsFile { + t.Helper() + var f limitsFile + raw, err := os.ReadFile(path) + if err != nil || json.Unmarshal(raw, &f) != nil { + t.Fatalf("read: %v %s", err, raw) + } + return f + } + pct := func(f limitsFile, name string) float64 { + var w limitWindow + json.Unmarshal(f.Limits[name], &w) + return w.UsedPercentage + } + + if err := writeClaudeLimits(path, "acme", "/x/acme", at, both); err != nil { + t.Fatal(err) + } + if f := got(); f.At != "2026-10-28T12:00:00.000Z" || pct(f, "five_hour") != 50 || pct(f, "seven_day") != 20 { + t.Fatalf("written: %+v", f) + } + // An older reading (another session's, said late) changes nothing. + older := json.RawMessage(`{"unifiedWindows":{"five_hour":{"utilization":0.1,"resetsAt":1793124000}}}`) + writeClaudeLimits(path, "acme", "/x/acme", at.Add(-time.Minute), older) + if f := got(); pct(f, "five_hour") != 50 { + t.Fatalf("an older reading was written: %+v", f) + } + // Nor does one with no windows. + writeClaudeLimits(path, "acme", "/x/acme", at.Add(time.Minute), json.RawMessage(`{"status":"allowed"}`)) + if f := got(); f.At != "2026-10-28T12:00:00.000Z" { + t.Fatalf("an empty reading was written: %+v", f) + } + // Another writer's file (a status line's, with keys of its own and a + // time without milliseconds) is read; a later reading replaces it. + os.WriteFile(path, []byte(`{"source":"router","at":"2026-10-28T13:00:00Z","limits":{"five_hour":{"used_percentage":70,"resets_at":1793124000},"seven_day":{"used_percentage":40,"resets_at":1793638800}}}`), 0o600) + writeClaudeLimits(path, "acme", "/x/acme", at.Add(30*time.Minute), older) + if f := got(); pct(f, "five_hour") != 70 { + t.Fatalf("a reading older than the file's was written: %+v", f) + } + writeClaudeLimits(path, "acme", "/x/acme", at.Add(2*time.Hour), older) + if f := got(); pct(f, "five_hour") != 10 || pct(f, "seven_day") != 40 { + t.Fatalf("after another writer's file: %+v", f) + } + // A file that isn't one is written afresh. + os.WriteFile(path, []byte("{not json"), 0o600) + if err := writeClaudeLimits(path, "acme", "/x/acme", at, both); err != nil { + t.Fatal(err) + } + if f := got(); pct(f, "five_hour") != 50 { + t.Fatalf("over a broken file: %+v", f) + } +} + +func TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt(t *testing.T) { + home := "/home/acme" + for dir, want := range map[string]string{ + "": "default", + "/home/acme/.claude": "default", + "/home/acme/.claude/": "default", + "~/.claude": "default", + "/home/acme/.berth/accounts/claude/work": "work", + "/home/acme/.berth/accounts/claude/work/": "work", + "$HOME/.berth/accounts/claude/max": "max", + "/": "", + "relative/dir": "", + } { + if got := claudeAccountName(home, dir); got != want { + t.Errorf("claudeAccountName(%q) = %q, want %q", dir, got, want) + } + } +} diff --git a/internal/box/claudelimits.go b/internal/box/claudelimits.go new file mode 100644 index 000000000..ef73e03ba --- /dev/null +++ b/internal/box/claudelimits.go @@ -0,0 +1,136 @@ +package box + +import ( + "context" + "encoding/json" + "math" + "os" + "path/filepath" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/integrations" + "github.com/cosscom/shipyard/internal/statefile" +) + +// Claude Code's plan limits: each turn of a structured session says how +// much of its account's 5-hour and weekly windows is used (a +// rate_limit_event). berthd keeps the latest in ~/.berth/limits/.json, +// where the Usage plugin reads them. Claude Code passes them otherwise only +// to a status line, which it runs only on its own screen, so a box whose +// agents all run structured would have none. A status line may write the +// same files (the plugin's README has the lines). + +// limitsMu keeps two sessions on one account from writing its file at +// once, an older reading after a newer one. A status line writing between +// the read and the rename isn't held by it: its reading can be lost, or an +// older one kept, until the account's next turn writes again. +var limitsMu sync.Mutex + +// claudeRateLimit is the part of a rate_limit_event's rate_limit_info the +// driver reads: one window or more, by name ("five_hour", "seven_day"). +type claudeRateLimit struct { + Windows map[string]struct { + Utilization *float64 `json:"utilization"` + ResetsAt *float64 `json:"resetsAt"` + } `json:"unifiedWindows"` +} + +// limitsFile is ~/.berth/limits/.json, in the shape a status line +// writes it: limits holds Claude Code's rate_limits as it passes them to +// one. +type limitsFile struct { + Account string `json:"account,omitempty"` + Dir string `json:"dir,omitempty"` + At string `json:"at"` + Limits map[string]json.RawMessage `json:"limits"` +} + +type limitWindow struct { + UsedPercentage float64 `json:"used_percentage"` + ResetsAt int64 `json:"resets_at"` +} + +// claudeAccountName is what the Usage plugin calls the Claude Code account +// in dir: "default" for ~/.claude, else the folder's name +// (~/.berth/accounts/claude/). "" when dir names no folder. +func claudeAccountName(home, dir string) string { + if dir == "" { + return "default" + } + d := integrations.ExpandAccountDir(home, dir) + switch { + case d == "" || filepath.Dir(d) == d: + return "" + case d == filepath.Join(home, ".claude"): + return "default" + } + return filepath.Base(d) +} + +// recordLimits keeps what a rate_limit_event at at says of the session's +// account. Called by speak, which alone uses c.account. +func (c *claudeConv) recordLimits(at time.Time, info json.RawMessage) { + home, err := os.UserHomeDir() + if err != nil { + return + } + if c.account == "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + sess, err := c.b.Sessions.Get(ctx, c.name) + if err != nil { + return + } + dir := c.b.claudeHomeFor(ctx, sess) + name := claudeAccountName(home, dir) + if name == "" { + return + } + c.account, c.accountDir = name, integrations.ExpandAccountDir(home, dir) + } + writeClaudeLimits(filepath.Join(home, ".berth", "limits", c.account+".json"), c.account, c.accountDir, at, info) +} + +// writeClaudeLimits writes path from a rate_limit_event's rate_limit_info, +// unless the file already holds a later reading. A window the event leaves +// out keeps what the file had; a file that isn't one is written afresh. +func writeClaudeLimits(path, account, dir string, at time.Time, info json.RawMessage) error { + var rl claudeRateLimit + if json.Unmarshal(info, &rl) != nil { + return nil + } + limits := map[string]json.RawMessage{} + for name, w := range rl.Windows { + if w.Utilization == nil || w.ResetsAt == nil { + continue + } + resets := *w.ResetsAt + if resets > 1e12 { + resets /= 1000 // milliseconds + } + raw, _ := json.Marshal(limitWindow{UsedPercentage: math.Round(*w.Utilization*10000) / 100, ResetsAt: int64(resets)}) + limits[name] = raw + } + if len(limits) == 0 { + return nil + } + limitsMu.Lock() + defer limitsMu.Unlock() + var old limitsFile + if raw, err := os.ReadFile(path); err == nil && json.Unmarshal(raw, &old) == nil { + if t, err := time.Parse(time.RFC3339, old.At); err == nil && !at.After(t) { + return nil + } + for name, w := range old.Limits { + if _, ok := limits[name]; !ok { + limits[name] = w + } + } + } + out, err := json.Marshal(limitsFile{Account: account, Dir: dir, At: at.UTC().Format("2006-01-02T15:04:05.000Z07:00"), Limits: limits}) + if err != nil { + return err + } + return statefile.Write(path, append(out, '\n')) +} diff --git a/plugins/usage/README.md b/plugins/usage/README.md index 50c9f9333..801790638 100644 --- a/plugins/usage/README.md +++ b/plugins/usage/README.md @@ -26,15 +26,17 @@ The numbers come from the files each agent writes on the box. Nothing is estimat | --- | --- | --- | | Claude Code | `~/.claude/projects/**/*.jsonl` and each account's `/projects` | `message.usage` on each assistant message, deduplicated by message and request id. It also holds Claude Code's own per-session `totalCostUSD`. | | Codex | `~/.codex/sessions/**/*.jsonl` and each account's `/sessions` | `token_count` events (cumulative totals per session) and the `rate_limits` Codex last saw. | -| Claude Code limits | `~/.berth/limits/.json`, written by your status line | The `rate_limits` Claude Code last passed to the status line. | +| Claude Code limits | `~/.berth/limits/.json`, written by the box (and by your status line, if you add the lines below) | The account's 5-hour and weekly windows as of the last turn a Claude Code agent took. | - **Input** excludes cached input. Cache reads and writes are shown separately; Codex's `input_tokens` includes its cached tokens, so the plugin subtracts them. - **Cost** is shown only for Claude Code, and only as Claude Code's own estimate at API list prices. On a subscription (Claude Max or Pro) the screen says the plan covers the use. Codex gets no dollar figure: it records none, and the plugin keeps no price table. -- **Plan limits** show the 5-hour and weekly windows, "as of" the last turn the agent took. A window that has reset since says so instead of showing an old percentage. Codex writes them into its transcripts. Claude Code passes them only to its [status line](https://code.claude.com/docs/en/statusline), so Claude Code's show once your status line records them (below). +- **Plan limits** show the 5-hour and weekly windows, "as of" the last turn the agent took. A window that has reset since says so instead of showing an old percentage. Codex writes them into its transcripts. Claude Code writes them nowhere, so the box records them (below). ### Claude Code's plan limits -Claude Code gives a status line its `rate_limits` (Pro and Max plans, after the session's first reply) and writes them nowhere else. To see them here, have your status line script save them, one file per account: +Each turn of a structured Claude Code agent (`claude -p`, the way Shipyard starts agents) tells the box how much of the account's windows is used, in a `rate_limit_event` (Pro and Max plans). berthd saves the latest reading to `~/.berth/limits/.json`, one file per account: `{"account", "dir", "at", "limits": {"five_hour": {"used_percentage", "resets_at"}, "seven_day": {…}}}`. It keeps the newest reading: one older than the file's `at` changes nothing, and a window a turn leaves out keeps what the file had. Readings in a log read back after berthd restarts are old and aren't saved. + +An agent in Claude Code's own terminal screen sends no such event. Claude Code gives its [status line](https://code.claude.com/docs/en/statusline) the same `rate_limits` instead, so if you use that screen, have your status line script save them to the same file: ```sh in=$(cat) # the JSON Claude Code passes your status line @@ -49,7 +51,7 @@ fi # …then print your status line from "$in" as before. ``` -The account is `default` for `~/.claude` and the folder's name for `~/.berth/accounts/claude/`. A file for any other name is ignored. The screen shows the limits as of `at`, so a box whose status line hasn't run for a while shows an old reading, labelled with its age. +The account is `default` for `~/.claude` and the folder's name for `~/.berth/accounts/claude/`. A file for any other name is ignored. The screen shows the limits as of `at`, so an account no agent has used for a while shows an old reading, labelled with its age. Any other keys in the file are ignored. ### How it runs on the box diff --git a/plugins/usage/box/usage.py b/plugins/usage/box/usage.py index 7de9be5d8..ae4290db8 100644 --- a/plugins/usage/box/usage.py +++ b/plugins/usage/box/usage.py @@ -238,11 +238,11 @@ def codex_file(path): def claude_limits(): - """The plan limits Claude Code last passed to a status line, from - ~/.berth/limits/.json: {"at": ISO time, "limits": the status - line's rate_limits}. Claude Code writes them nowhere else, so a status - line has to record them (see the README); without the file there are - none.""" + """The plan limits a Claude Code agent last reported, from + ~/.berth/limits/.json: {"at": ISO time, "limits": the windows + in a status line's rate_limits shape}. berthd writes it from each + structured turn, and a status line can too (see the README); without + the file there are none.""" out = [] for name, _ in claude_dirs(): o = read_json(LIMITS / f"{name}.json") From 6a48d282c0274652dd1870bd394ed1b3c9ca23d9 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Wed, 28 Oct 2026 16:10:40 +0000 Subject: [PATCH 3/4] test(skills): verify-berthd drives the usage screen's claude limits on a real box usage-limits-live turns the Usage plugin on, puts a dev box's new sessions on a linked account, runs one Haiku turn and checks the limits file, Claude Code's own /usage (claude-usage.sh) and the screen's meters agree. dev-box.sh can give the box a HOME of its own (VERIFY_BOX_HOME) with a signed-in account linked in as acme-max, and drive.mjs's shot() can keep a single element. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-berthd/SKILL.md | 2 +- .../skills/verify-berthd/features/README.md | 1 + .../verify-berthd/features/usage-limits.md | 34 ++++++ .../verify-berthd/scripts/claude-usage.sh | 46 ++++++++ .../skills/verify-berthd/scripts/dev-box.sh | 17 +++ .../skills/verify-berthd/scripts/drive.mjs | 8 +- .../scripts/scenarios/usage-limits-live.mjs | 100 ++++++++++++++++++ 7 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/verify-berthd/features/usage-limits.md create mode 100755 .claude/skills/verify-berthd/scripts/claude-usage.sh create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs diff --git a/.claude/skills/verify-berthd/SKILL.md b/.claude/skills/verify-berthd/SKILL.md index d484f15e8..9b9b67118 100644 --- a/.claude/skills/verify-berthd/SKILL.md +++ b/.claude/skills/verify-berthd/SKILL.md @@ -42,7 +42,7 @@ node .claude/skills/verify-berthd/scripts/drive.mjs --port $PORT [--o - opens `http://localhost:$PORT/?mock=1` at 1440×900 in dark mode, and waits until the sidebar's `nav-home` is there; - refuses every request to the laptop agent's ports (1377-1379), as the e2e fixtures do; - serves the scenario's `plugins` as extra built-in plugins (it appends them to `builtin-plugins/index.json` and answers `builtin-plugins//index.js` with their source), so a plugin API is exercised by a real plugin loaded the way the app loads its own; -- calls the scenario's `run({ page, shot, step })`, where `shot(name)` saves `.png` and `.aria.txt` (an ARIA snapshot of the body), and `step(text)` logs what is being done; +- calls the scenario's `run({ page, shot, step })`, where `shot(name)` saves `.png` and `.aria.txt` (an ARIA snapshot of the body; `shot(name, locator)` keeps only that element, for a screen that also shows what stays out of evidence), and `step(text)` logs what is being done; - fails (exit 1) on a page error, or a console error the scenario doesn't list in `allowErrors` (regexes), and on any assertion the scenario throws. A scenario is a module: diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..80904c9ee 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -18,6 +18,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it | | Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | +| Usage: Claude Code's 5-hour and weekly limits from structured agents, the same as `/usage` | [usage-limits.md](usage-limits.md) | `usage-limits-live` (a real box with its own HOME, `--agent`); `internal/box/claudedriver_test.go` asserts the box's side | | Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock | | Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it | | Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box | diff --git a/.claude/skills/verify-berthd/features/usage-limits.md b/.claude/skills/verify-berthd/features/usage-limits.md new file mode 100644 index 000000000..f947618b0 --- /dev/null +++ b/.claude/skills/verify-berthd/features/usage-limits.md @@ -0,0 +1,34 @@ +# Usage: Claude Code's plan limits + +The Usage plugin (`plugins/usage`, off until turned on in Settings → Plugins) shows each agent's 5-hour and weekly plan windows: how much is used, as of when, and when each resets. Codex's come from its transcripts. Claude Code's come from `~/.berth/limits/.json`, which the box writes from every structured turn (each turn's `rate_limit_event`, `internal/box/claudelimits.go`), and which a status line can also write for agents on Claude Code's own screen (the plugin's README). + +## Sub-features + +- `limits-structured`: a turn of a structured Claude Code agent writes its account's file: `used_percentage` is the event's utilization × 100, `resets_at` in seconds. A newer reading replaces an older one, a window the turn leaves out keeps the file's, and a log read back after berthd restarts writes nothing. All of that is unit-tested in `internal/box/claudedriver_test.go` (`TestAStructuredSessionRecordsItsAccountsPlanLimits`, `TestPlanLimitsKeepTheLatestReading`). +- `limits-account`: the file is named as the plugin names the account: `default` for `~/.claude`, the folder's name for `~/.berth/accounts/claude/` (`TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt`). +- `limits-screen`: the Usage tab's Claude Code panel shows the file's windows as meters ("5-hour limit", "Weekly limit"), the same numbers Claude Code's own `/usage` shows. Home's **Agent usage** widget shows them too. + +## How to get to it (user POV) + +- Settings → Plugins → turn on **Usage & accounts**; then **Usage** (sidebar, under More) or ⌘K **Show agent usage**. **Switch agent account** opens its Accounts tab, where an account's **…** menu sets which login new sessions use. + +## Driving it with drive.mjs + +- `../scripts/scenarios/usage-limits-live.mjs`, on a real box: it needs a signed-in Claude account and costs one short Haiku turn. The dev box gets a HOME of its own (so it never writes the real `~/.berth/limits`) with the account linked in as `acme-max`: + + ```sh + export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT= VERIFY_DRIVER=claude + ../scripts/dev-box.sh up && eval "$(../scripts/dev-box.sh env)" + node ../scripts/drive.mjs usage-limits-live --port $PORT --agent http://127.0.0.1:9378 + ``` + + It turns the plugin on, picks acme-max for the box in the Accounts tab, starts session `limits` (structured, Haiku, "Reply with just: ok"), waits for `acme-max.json`, reads `/usage` with `../scripts/claude-usage.sh` and checks that the file, `/usage` and the screen's meters agree. It removes the session after; `dev-box.sh down` removes the rest. +- `../scripts/claude-usage.sh DIR` prints what `/usage` says for the account in `DIR` (`five_hour=45 seven_day=23`). It starts Claude Code's own screen in a tmux server of its own and trusts an empty `/tmp/claude-usage-w` the first time. + +## Gotchas + +- An agent still running at `dev-box.sh down` writes its folders again as it exits, after the box's folder is removed: under `VERIFY_BOX_HOME`, `acme-max` comes back as a folder signed in nowhere ("Not logged in"). The scenario removes its session even when it fails, and `up` refuses such a leftover; remove `VERIFY_BOX_DIR` a few seconds after `down`. +- Run with every `BERTH_*` unset (a /ship worker's `BERTH_DRIVER=structured` leaks into the dev box's agents). +- The linked account is a real one: the Usage screen lists its real sessions and projects. Shoot only the limits rows (`shot(name, locator)`), never the whole screen, and keep `failed.png` out of a PR. +- `/usage` and the file are two readings a few seconds apart: on an account other agents are using at the same time, they can differ by a point. Run it on an account nothing else is using. +- The plugin reads limits only for known accounts (`default` and the folders under `~/.berth/accounts/claude`); a file for any other name is ignored. diff --git a/.claude/skills/verify-berthd/scripts/claude-usage.sh b/.claude/skills/verify-berthd/scripts/claude-usage.sh new file mode 100755 index 000000000..3b4d37ff1 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/claude-usage.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# claude-usage.sh DIR [WORKDIR]: what Claude Code's own /usage says of the +# account in DIR (a CLAUDE_CONFIG_DIR), the ground truth for the Usage +# screen's Claude limits (features/usage-limits.md). It starts Claude Code +# on its own screen in a tmux server of its own, trusts WORKDIR (default +# /tmp/claude-usage-w, made empty) if asked, types /usage, and prints +# five_hour=45 seven_day=23 +# Nothing else: the panel also lists the account's sessions and MCP +# servers, which stay out of evidence. +set -euo pipefail +dir=${1:?usage: claude-usage.sh CLAUDE_CONFIG_DIR [WORKDIR]} +work=${2:-/tmp/claude-usage-w} +sock=$(mktemp -d /tmp/claude-usage-tmux.XXXXXX) +tm() { env TMUX_TMPDIR="$sock" tmux -L usage "$@"; } +trap 'tm kill-server 2>/dev/null || true; rm -rf "$sock"' EXIT +mkdir -p "$work" +tm new-session -d -s u -x 160 -y 60 -c "$work" "env CLAUDE_CONFIG_DIR=$dir claude --model haiku" +screen() { tm capture-pane -p -t u -S -80; } +# Until its prompt shows, saying yes to the folder's trust question. Its +# cursor starts on "No, exit" and the screen shows before it reads keys, +# so Enter goes only once the cursor is on yes. +for _ in $(seq 60); do + s=$(screen) + if grep -q "❯ Yes, I trust this folder" <<<"$s"; then + tm send-keys -t u Enter + elif grep -q "Yes, I trust this folder" <<<"$s"; then + tm send-keys -t u Down + elif grep -q "❯" <<<"$s" && ! grep -q "Enter to confirm" <<<"$s"; then + break + fi + sleep 0.5 +done +# A notice can take the first keys: type /usage until its panel shows. +for _ in 1 2 3; do + tm send-keys -t u "/usage" && sleep 1.5 && tm send-keys -t u Enter + for _ in $(seq 30); do + s=$(screen) + grep -q "Current week (all models)" <<<"$s" && break 2 + sleep 0.5 + done +done +pct() { grep -A1 -F "$1" <<<"$s" | grep -o '[0-9]\+% used' | head -1 | tr -dc 0-9; } +five=$(pct "Current session") +week=$(pct "Current week (all models)") +[[ -n $five && -n $week ]] || { echo "no /usage panel on screen" >&2; exit 1; } +echo "five_hour=$five seven_day=$week" diff --git a/.claude/skills/verify-berthd/scripts/dev-box.sh b/.claude/skills/verify-berthd/scripts/dev-box.sh index 78b589c22..85174ba62 100755 --- a/.claude/skills/verify-berthd/scripts/dev-box.sh +++ b/.claude/skills/verify-berthd/scripts/dev-box.sh @@ -17,12 +17,18 @@ # HOME stays real: Claude Code needs its sign-in. The box refreshes # Claude's hooks in ~ only when they are out of date; `up` stops if it # did, so a dev box never changes the real one's hooks unnoticed. +# VERIFY_BOX_HOME= gives the box a HOME of its own instead, for what +# it writes there (the Usage plugin's ~/.berth/limits), and +# VERIFY_CLAUDE_ACCOUNT= links that folder +# into it as the Claude account "acme-max" (features/usage-limits.md), so +# no real account's name shows. set -euo pipefail root=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) # Short: a unix socket's path can't be longer than 108 bytes. dir=${VERIFY_BOX_DIR:-/tmp/verify-berthd-box} addr=${VERIFY_BOX_ADDR:-127.0.0.1:7544} box_env=(BERTH_HOME="$dir/box-home" BERTH_USER_DIR="$dir/box-user" TMUX_TMPDIR="$dir/tmux") +[[ -n ${VERIFY_BOX_HOME:-} ]] && box_env+=(HOME="$VERIFY_BOX_HOME") lap_env=(BERTH_HOME="$dir/lap-home" BERTH_USER_DIR="$dir/lap-user" BERTH_UI_ADDR=127.0.0.1:9378 BERTH_PROXY_ADDR=127.0.0.1:9377) box() { env "${box_env[@]}" "$root/bin/berthd" "$@"; } lap() { env "${lap_env[@]}" "$root/bin/berth" "$@"; } @@ -46,6 +52,17 @@ up) if ss -ltn | grep -q ":$p "; then echo "port $p is taken: is a dev box up already? ($0 down)" >&2; exit 1; fi done mkdir -p "$dir"/{box-home,box-user,lap-home,lap-user,tmux} && chmod 700 "$dir/tmux" + if [[ -n ${VERIFY_BOX_HOME:-} && -n ${VERIFY_CLAUDE_ACCOUNT:-} ]]; then + link=$VERIFY_BOX_HOME/.berth/accounts/claude/acme-max + # An agent still running when the last dev box went down writes its + # account folder again as it exits: a folder, signed in nowhere. + if [[ -e $link && ! -L $link ]]; then + echo "$link is a folder, not the link to $VERIFY_CLAUDE_ACCOUNT (a leftover of the last dev box?): remove it first" >&2 + exit 1 + fi + mkdir -p "$(dirname "$link")" + ln -sfn "$VERIFY_CLAUDE_ACCOUNT" "$link" + fi make -C "$root" build >/dev/null serve "$root/bin/berthd" if grep -q "updated berth's hooks" "$dir/berthd.log"; then diff --git a/.claude/skills/verify-berthd/scripts/drive.mjs b/.claude/skills/verify-berthd/scripts/drive.mjs index 4917aae96..6d7a7a8aa 100755 --- a/.claude/skills/verify-berthd/scripts/drive.mjs +++ b/.claude/skills/verify-berthd/scripts/drive.mjs @@ -79,9 +79,11 @@ if (plugins.length) { for (const p of plugins) await page.route(`**/builtin-plugins/${p.id}/index.js`, (r) => r.fulfill({ contentType: "text/javascript", body: p.source })); } -const shot = async (label) => { - await page.screenshot({ path: join(out, `${label}.png`) }); - writeFileSync(join(out, `${label}.aria.txt`), redact(await page.locator("body").ariaSnapshot())); +// shot(label, locator) keeps only that part of the page, for a screen that +// also shows what stays out of evidence. +const shot = async (label, part) => { + await (part ?? page).screenshot({ path: join(out, `${label}.png`) }); + writeFileSync(join(out, `${label}.aria.txt`), redact(await (part ?? page.locator("body")).ariaSnapshot())); console.log(` saved ${label}.png`); }; diff --git a/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs new file mode 100644 index 000000000..e46be3c64 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs @@ -0,0 +1,100 @@ +// Claude Code's plan limits on the Usage screen, from a structured agent +// (features/usage-limits.md): the Usage plugin is turned on, its Accounts +// tab puts the box's new sessions on account "acme-max", one cheap +// structured turn runs there, and the box writes what that turn said of the +// account's limits to ~/.berth/limits/acme-max.json. The screen's 5-hour +// and weekly meters show the same numbers as the file, and as Claude Code's +// own /usage (claude-usage.sh), the ground truth. +// +// On a dev box with a HOME of its own and a signed-in account linked in +// (../dev-box.sh): +// +// export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT= +// dev-box.sh up && eval "$(dev-box.sh env)" +// node drive.mjs usage-limits-live --port $PORT --agent http://127.0.0.1:9378 +// +// The screen also lists the account's sessions and projects, so the shots +// keep only the limits. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const live = true; + +const BOX = (process.env.VERIFY_WORKTREE ?? "devq/acme-shop").split("/")[0]; +const HOME = process.env.VERIFY_BOX_HOME; +const ACCOUNT = process.env.VERIFY_CLAUDE_ACCOUNT; +const SESSION = "limits"; + +const limitsFile = () => { + try { + return JSON.parse(readFileSync(join(HOME, ".berth", "limits", "acme-max.json"), "utf8")); + } catch { + return null; + } +}; + +export async function run({ page, shot, step, expect, call }) { + if (!HOME || !ACCOUNT) throw new Error("set VERIFY_BOX_HOME and VERIFY_CLAUDE_ACCOUNT, as dev-box.sh up had them"); + if (limitsFile()) throw new Error("the box already has limits for acme-max: start from a fresh dev box"); + + step("turn on Usage & accounts in Settings → Plugins"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-plugins").click(); + const plugins = page.getByTestId("settings-plugins"); + await plugins.getByRole("switch", { name: "Turn on Usage & accounts" }).click({ timeout: 15_000 }); + await expect(plugins.getByRole("switch", { name: "Turn off Usage & accounts" })).toBeVisible(); + await page.getByRole("button", { name: "Back to app" }).click(); + await expect(page.getByTestId("nav-home")).toBeVisible(); + + step("open Usage, Accounts: new sessions on the box use acme-max"); + await page.keyboard.press("Meta+k"); + await page.keyboard.type("Switch agent account"); + await page.getByRole("option", { name: /^Switch agent account/ }).click(); + await page.getByRole("button", { name: "Use acme-max for…" }).click({ timeout: 60_000 }); + await page.getByRole("menuitem", { name: `Everywhere on ${BOX}` }).click(); + await expect.poll(async () => (await call(`/v1/boxes/${BOX}/api/env`)).env?.CLAUDE_CONFIG_DIR ?? "", { timeout: 15_000 }).toContain("acme-max"); + + step("one cheap structured turn on acme-max"); + const started = Date.now(); + await call(`/v1/boxes/${BOX}/api/sessions`, { method: "POST", body: JSON.stringify({ name: SESSION, location: "acme-shop", agent: "claude", driver: "claude", model: "haiku", prompt: "Reply with just: ok" }) }); + try { + await checkLimits({ page, shot, step, expect, started }); + } finally { + // Gone before the dev box is: an agent still running when it goes + // down writes its account folder again (dev-box.sh up refuses it). + await call(`/v1/boxes/${BOX}/api/sessions/${SESSION}`, { method: "DELETE" }).catch(() => {}); + } +} + +async function checkLimits({ page, shot, step, expect, started }) { + await expect.poll(() => limitsFile()?.at ?? "", { timeout: 180_000, intervals: [1_000] }).not.toBe(""); + const file = limitsFile(); + const five = Math.round(file.limits.five_hour.used_percentage); + const week = Math.round(file.limits.seven_day.used_percentage); + if (Date.parse(file.at) < started - 5_000) throw new Error(`the file's reading is older than the turn: ${file.at}`); + step(`the box wrote acme-max.json: 5-hour ${five}%, weekly ${week}%, as of ${file.at}`); + + const truth = execFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "claude-usage.sh"), [ACCOUNT], { encoding: "utf8" }).trim(); + const usage = Object.fromEntries(truth.split(" ").map((kv) => kv.split("=").map((v, i) => (i ? Number(v) : v)))); + step(`Claude Code's /usage: 5-hour ${usage.five_hour}%, weekly ${usage.seven_day}%`); + // Two readings seconds apart: an account in use elsewhere can move a + // point between them. + if (Math.abs(usage.five_hour - five) > 1 || Math.abs(usage.seven_day - week) > 1) throw new Error(`the file (${five}%, ${week}%) isn't what /usage says (${truth})`); + + step("the Usage screen shows the same"); + await page.getByRole("group", { name: "Show" }).getByText("Usage", { exact: true }).click(); + // The screen read the box when it opened, before the turn; it reads again + // every 5 minutes, or now with Refresh. + await page.getByRole("button", { name: "Refresh" }).first().click(); + const fiveMeter = page.getByRole("meter", { name: "5-hour limit" }); + const weekMeter = page.getByRole("meter", { name: "Weekly limit" }); + await expect(fiveMeter).toHaveAttribute("aria-valuenow", String(five), { timeout: 90_000 }); + await expect(weekMeter).toHaveAttribute("aria-valuenow", String(week)); + const rows = fiveMeter.locator("xpath=ancestor::div[contains(@class,'border-t')][1]"); + await expect(rows).toContainText(`${five}% used`); + await expect(rows).toContainText(`${week}% used`); + await shot("1-usage-claude-limits", rows); +} From 8bda77d4ede075d027b1ac67aaf3dc7d86685cd1 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Thu, 29 Oct 2026 20:32:59 +0000 Subject: [PATCH 4/4] fix(plugins): claude limits show in today's view after midnight, and only for known accounts - The Usage screen and Home widget pick limits by the box's own day, as they do tokens, instead of the UTC date of the reading (a reading just after local midnight east of UTC dropped out of Today, Codex's too). - The box records limits only for ~/.claude and ~/.berth/accounts/claude/*, the accounts the plugin knows, so a folder elsewhere named like one isn't taken for it. The README's status-line lines do the same and write through a temp file of their own. - verify-berthd: claude-usage.sh passes CLAUDE_CONFIG_DIR through tmux's environment and empties its default folder; the live scenario removes its session even when starting it fails and rejects a bad timestamp; its commands run from the repository root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018aGVrwQaYK6hFHuTMpR2wB --- .../verify-berthd/features/usage-limits.md | 8 ++++---- .../verify-berthd/scripts/claude-usage.sh | 6 ++++-- .../scripts/scenarios/usage-limits-live.mjs | 8 +++++--- app/src/lib/mock-usage.ts | 2 ++ docs/changelog.mdx | 2 ++ internal/box/claudedriver_test.go | 3 +++ internal/box/claudelimits.go | 11 ++++++----- plugins/usage/README.md | 19 +++++++++++++------ plugins/usage/box/usage.py | 4 ++-- plugins/usage/src/box.ts | 2 ++ plugins/usage/src/home.tsx | 2 +- plugins/usage/src/usage-view.tsx | 2 +- 12 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.claude/skills/verify-berthd/features/usage-limits.md b/.claude/skills/verify-berthd/features/usage-limits.md index f947618b0..68adb5c7d 100644 --- a/.claude/skills/verify-berthd/features/usage-limits.md +++ b/.claude/skills/verify-berthd/features/usage-limits.md @@ -5,7 +5,7 @@ The Usage plugin (`plugins/usage`, off until turned on in Settings → Plugins) ## Sub-features - `limits-structured`: a turn of a structured Claude Code agent writes its account's file: `used_percentage` is the event's utilization × 100, `resets_at` in seconds. A newer reading replaces an older one, a window the turn leaves out keeps the file's, and a log read back after berthd restarts writes nothing. All of that is unit-tested in `internal/box/claudedriver_test.go` (`TestAStructuredSessionRecordsItsAccountsPlanLimits`, `TestPlanLimitsKeepTheLatestReading`). -- `limits-account`: the file is named as the plugin names the account: `default` for `~/.claude`, the folder's name for `~/.berth/accounts/claude/` (`TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt`). +- `limits-account`: the file is named as the plugin names the account: `default` for `~/.claude`, the folder's name for `~/.berth/accounts/claude/`, and no file for any other folder (`TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt`). - `limits-screen`: the Usage tab's Claude Code panel shows the file's windows as meters ("5-hour limit", "Weekly limit"), the same numbers Claude Code's own `/usage` shows. Home's **Agent usage** widget shows them too. ## How to get to it (user POV) @@ -17,9 +17,9 @@ The Usage plugin (`plugins/usage`, off until turned on in Settings → Plugins) - `../scripts/scenarios/usage-limits-live.mjs`, on a real box: it needs a signed-in Claude account and costs one short Haiku turn. The dev box gets a HOME of its own (so it never writes the real `~/.berth/limits`) with the account linked in as `acme-max`: ```sh - export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT= VERIFY_DRIVER=claude - ../scripts/dev-box.sh up && eval "$(../scripts/dev-box.sh env)" - node ../scripts/drive.mjs usage-limits-live --port $PORT --agent http://127.0.0.1:9378 + export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT=/path/to/signed-in/claude-config-dir VERIFY_DRIVER=claude + .claude/skills/verify-berthd/scripts/dev-box.sh up && eval "$(.claude/skills/verify-berthd/scripts/dev-box.sh env)" + node .claude/skills/verify-berthd/scripts/drive.mjs usage-limits-live --port $PORT --agent http://127.0.0.1:9378 ``` It turns the plugin on, picks acme-max for the box in the Accounts tab, starts session `limits` (structured, Haiku, "Reply with just: ok"), waits for `acme-max.json`, reads `/usage` with `../scripts/claude-usage.sh` and checks that the file, `/usage` and the screen's meters agree. It removes the session after; `dev-box.sh down` removes the rest. diff --git a/.claude/skills/verify-berthd/scripts/claude-usage.sh b/.claude/skills/verify-berthd/scripts/claude-usage.sh index 3b4d37ff1..a42316a74 100755 --- a/.claude/skills/verify-berthd/scripts/claude-usage.sh +++ b/.claude/skills/verify-berthd/scripts/claude-usage.sh @@ -9,12 +9,14 @@ # servers, which stay out of evidence. set -euo pipefail dir=${1:?usage: claude-usage.sh CLAUDE_CONFIG_DIR [WORKDIR]} -work=${2:-/tmp/claude-usage-w} +# The default folder keeps its path, so Claude Code asks to trust it only +# once, and is emptied every run so nothing from another one is in it. +if [[ -n ${2:-} ]]; then work=$2; else work=/tmp/claude-usage-w; rm -rf "$work"; fi sock=$(mktemp -d /tmp/claude-usage-tmux.XXXXXX) tm() { env TMUX_TMPDIR="$sock" tmux -L usage "$@"; } trap 'tm kill-server 2>/dev/null || true; rm -rf "$sock"' EXIT mkdir -p "$work" -tm new-session -d -s u -x 160 -y 60 -c "$work" "env CLAUDE_CONFIG_DIR=$dir claude --model haiku" +tm new-session -d -s u -x 160 -y 60 -c "$work" -e "CLAUDE_CONFIG_DIR=$dir" claude --model haiku screen() { tm capture-pane -p -t u -S -80; } # Until its prompt shows, saying yes to the folder's trust question. Its # cursor starts on "No, exit" and the screen shows before it reads keys, diff --git a/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs index e46be3c64..b551bd840 100644 --- a/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs +++ b/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs @@ -9,7 +9,7 @@ // On a dev box with a HOME of its own and a signed-in account linked in // (../dev-box.sh): // -// export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT= +// export VERIFY_BOX_DIR=/tmp/vb-limits VERIFY_BOX_HOME=/tmp/vb-limits/home VERIFY_CLAUDE_ACCOUNT=/path/to/signed-in/claude-config-dir // dev-box.sh up && eval "$(dev-box.sh env)" // node drive.mjs usage-limits-live --port $PORT --agent http://127.0.0.1:9378 // @@ -59,8 +59,9 @@ export async function run({ page, shot, step, expect, call }) { step("one cheap structured turn on acme-max"); const started = Date.now(); - await call(`/v1/boxes/${BOX}/api/sessions`, { method: "POST", body: JSON.stringify({ name: SESSION, location: "acme-shop", agent: "claude", driver: "claude", model: "haiku", prompt: "Reply with just: ok" }) }); try { + // Inside the try: a POST that times out may still have started it. + await call(`/v1/boxes/${BOX}/api/sessions`, { method: "POST", body: JSON.stringify({ name: SESSION, location: "acme-shop", agent: "claude", driver: "claude", model: "haiku", prompt: "Reply with just: ok" }) }); await checkLimits({ page, shot, step, expect, started }); } finally { // Gone before the dev box is: an agent still running when it goes @@ -74,7 +75,8 @@ async function checkLimits({ page, shot, step, expect, started }) { const file = limitsFile(); const five = Math.round(file.limits.five_hour.used_percentage); const week = Math.round(file.limits.seven_day.used_percentage); - if (Date.parse(file.at) < started - 5_000) throw new Error(`the file's reading is older than the turn: ${file.at}`); + const at = Date.parse(file.at); + if (!Number.isFinite(at) || at < started - 5_000) throw new Error(`the file's reading isn't from this turn: ${file.at}`); step(`the box wrote acme-max.json: 5-hour ${five}%, weekly ${week}%, as of ${file.at}`); const truth = execFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "claude-usage.sh"), [ACCOUNT], { encoding: "utf8" }).trim(); diff --git a/app/src/lib/mock-usage.ts b/app/src/lib/mock-usage.ts index 68b356fef..ddb2e173d 100644 --- a/app/src/lib/mock-usage.ts +++ b/app/src/lib/mock-usage.ts @@ -143,6 +143,7 @@ function report(box: string) { agent: "codex", account: "default", at: at(gpu ? 5 : 0.05), + day: iso(new Date(at(gpu ? 5 : 0.05))), limits: { plan_type: "pro", primary: { used_percent: 34, window_minutes: 300, resets_at: Math.round(now.getTime() / 1000 + 3 * 3600 + 1200) }, @@ -153,6 +154,7 @@ function report(box: string) { agent: "claude", account: "default", at: at(gpu ? 3 : 0.02), + day: iso(new Date(at(gpu ? 3 : 0.02))), limits: { primary: { used_percent: 18, window_minutes: 300, resets_at: Math.round(now.getTime() / 1000 + 2 * 3600 + 600) }, secondary: { used_percent: 41, window_minutes: 10080, resets_at: Math.round(now.getTime() / 1000 + 3 * 86400 + 7200) }, diff --git a/docs/changelog.mdx b/docs/changelog.mdx index a48c39cbd..66c21b9ee 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -53,6 +53,8 @@ again. show it beside Codex's: how much is used, as of when, and when each window resets. An agent in Claude Code's own terminal screen can add its readings from a status line; the plugin's README has the lines. + Limits, Codex's too, now count by the box's own day, so a reading just + after midnight no longer drops out of Today. ## v0.3.31 diff --git a/internal/box/claudedriver_test.go b/internal/box/claudedriver_test.go index 5b46e7c29..fbf00f7f8 100644 --- a/internal/box/claudedriver_test.go +++ b/internal/box/claudedriver_test.go @@ -1159,6 +1159,9 @@ func TestAClaudeAccountIsNamedAsTheUsagePluginNamesIt(t *testing.T) { "$HOME/.berth/accounts/claude/max": "max", "/": "", "relative/dir": "", + "/home/acme/elsewhere/work": "", + "/home/acme/.berth/accounts/claude": "", + "/home/acme/.berth/accounts/codex/work": "", } { if got := claudeAccountName(home, dir); got != want { t.Errorf("claudeAccountName(%q) = %q, want %q", dir, got, want) diff --git a/internal/box/claudelimits.go b/internal/box/claudelimits.go index ef73e03ba..387cdfdc0 100644 --- a/internal/box/claudelimits.go +++ b/internal/box/claudelimits.go @@ -52,20 +52,21 @@ type limitWindow struct { } // claudeAccountName is what the Usage plugin calls the Claude Code account -// in dir: "default" for ~/.claude, else the folder's name -// (~/.berth/accounts/claude/). "" when dir names no folder. +// in dir: "default" for ~/.claude, the folder's name for +// ~/.berth/accounts/claude/. "" for any other folder: the plugin +// knows no other, and one elsewhere named like an account isn't it. func claudeAccountName(home, dir string) string { if dir == "" { return "default" } d := integrations.ExpandAccountDir(home, dir) switch { - case d == "" || filepath.Dir(d) == d: - return "" case d == filepath.Join(home, ".claude"): return "default" + case d != "" && filepath.Dir(d) == integrations.AccountsDir(home, "claude"): + return filepath.Base(d) } - return filepath.Base(d) + return "" } // recordLimits keeps what a rate_limit_event at at says of the session's diff --git a/plugins/usage/README.md b/plugins/usage/README.md index 801790638..7353e0696 100644 --- a/plugins/usage/README.md +++ b/plugins/usage/README.md @@ -34,7 +34,7 @@ The numbers come from the files each agent writes on the box. Nothing is estimat ### Claude Code's plan limits -Each turn of a structured Claude Code agent (`claude -p`, the way Shipyard starts agents) tells the box how much of the account's windows is used, in a `rate_limit_event` (Pro and Max plans). berthd saves the latest reading to `~/.berth/limits/.json`, one file per account: `{"account", "dir", "at", "limits": {"five_hour": {"used_percentage", "resets_at"}, "seven_day": {…}}}`. It keeps the newest reading: one older than the file's `at` changes nothing, and a window a turn leaves out keeps what the file had. Readings in a log read back after berthd restarts are old and aren't saved. +Each turn of a structured Claude Code agent (one Shipyard starts and follows, with `claude -p`) tells the box how much of the account's windows is used, in a `rate_limit_event` (Pro and Max plans). berthd saves the latest reading to `~/.berth/limits/.json`, one file per account: `{"account", "dir", "at", "limits": {"five_hour": {"used_percentage", "resets_at"}, "seven_day": {…}}}`. It keeps the newest reading: one older than the file's `at` changes nothing, and a window a turn leaves out keeps what the file had. Readings in a log read back after berthd restarts are old and aren't saved. A `claude -p` you run yourself, outside Shipyard, isn't followed, so it records nothing. An agent in Claude Code's own terminal screen sends no such event. Claude Code gives its [status line](https://code.claude.com/docs/en/statusline) the same `rate_limits` instead, so if you use that screen, have your status line script save them to the same file: @@ -43,15 +43,22 @@ in=$(cat) # the JSON Claude Code passes your status line limits=$(printf '%s' "$in" | jq -c '.rate_limits // empty') if [ -n "$limits" ]; then dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude}; dir=${dir%/} - account=$(basename "$dir"); [ "$dir" = "$HOME/.claude" ] && account=default - mkdir -p ~/.berth/limits - jq -n --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson limits "$limits" '{at: $at, limits: $limits}' \ - > ~/.berth/limits/"$account".tmp && mv ~/.berth/limits/"$account".tmp ~/.berth/limits/"$account".json + case $dir in + "$HOME/.claude") account=default ;; + "$HOME"/.berth/accounts/claude/*) account=$(basename "$dir") ;; + *) account= ;; + esac + if [ -n "$account" ]; then + mkdir -p ~/.berth/limits + tmp=~/.berth/limits/"$account".$$.tmp # one per run: sessions on one account can overlap + jq -n --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson limits "$limits" '{at: $at, limits: $limits}' > "$tmp" \ + && mv "$tmp" ~/.berth/limits/"$account".json + fi fi # …then print your status line from "$in" as before. ``` -The account is `default` for `~/.claude` and the folder's name for `~/.berth/accounts/claude/`. A file for any other name is ignored. The screen shows the limits as of `at`, so an account no agent has used for a while shows an old reading, labelled with its age. Any other keys in the file are ignored. +The account is `default` for `~/.claude` and the folder's name for `~/.berth/accounts/claude/`. A file for any other name is ignored. The screen shows the limits as of `at`, labelled with its age, as long as `at` falls in the period it shows (today, 7 or 30 days), as Codex's do: an account no agent has used in that period shows none. Any other keys in the file are ignored. ### How it runs on the box diff --git a/plugins/usage/box/usage.py b/plugins/usage/box/usage.py index ae4290db8..b039b774e 100644 --- a/plugins/usage/box/usage.py +++ b/plugins/usage/box/usage.py @@ -255,7 +255,7 @@ def claude_limits(): windows[slot] = {"used_percent": w["used_percentage"], "window_minutes": minutes, "resets_at": int(w["resets_at"])} at = iso(o.get("at") or "") if windows and at: - out.append({"agent": "claude", "account": name, "at": at, "limits": windows}) + out.append({"agent": "claude", "account": name, "at": at, "day": local_day(at), "limits": windows}) return out @@ -335,7 +335,7 @@ def report(days): if m not in row["models"]: row["models"].append(m) if s.get("limits") and (account not in limits or s["limits_at"] > limits[account]["at"]): - limits[account] = {"agent": agent, "account": account, "at": s["limits_at"], "limits": s["limits"]} + limits[account] = {"agent": agent, "account": account, "at": s["limits_at"], "day": local_day(s["limits_at"]), "limits": s["limits"]} out = { "generated": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"), diff --git a/plugins/usage/src/box.ts b/plugins/usage/src/box.ts index 73d48108a..a03876852 100644 --- a/plugins/usage/src/box.ts +++ b/plugins/usage/src/box.ts @@ -49,6 +49,8 @@ export interface Limits { agent: Agent; account: string; at: string; + // The box's own day at `at`, as the days of its tokens are. + day: string; limits: { plan_type?: string | null; primary?: Window | null; secondary?: Window | null }; } diff --git a/plugins/usage/src/home.tsx b/plugins/usage/src/home.tsx index 5ba5c978f..c50b0f7e5 100644 --- a/plugins/usage/src/home.tsx +++ b/plugins/usage/src/home.tsx @@ -46,7 +46,7 @@ export function summarizeUsage(reports: { box: string; report: Report }[], faile // The newest limits each login reported this week. const newest = new Map(); for (const l of report.limits) { - if (l.at.slice(0, 10) < from) continue; + if (l.day < from) continue; const k = `${l.agent}/${l.account}`; if (!newest.has(k) || newest.get(k)!.at < l.at) newest.set(k, l); } diff --git a/plugins/usage/src/usage-view.tsx b/plugins/usage/src/usage-view.tsx index b777e00c7..9b406cad0 100644 --- a/plugins/usage/src/usage-view.tsx +++ b/plugins/usage/src/usage-view.tsx @@ -277,7 +277,7 @@ function dedupeLimits(agent: Agent, sources: Source[], accounts: Record(); for (const { box, report } of sources) { const from = firstDay(report.today, period); - for (const l of report.limits.filter((x) => x.agent === agent && x.at.slice(0, 10) >= from)) { + for (const l of report.limits.filter((x) => x.agent === agent && x.day >= from)) { const a = accounts[box]?.find((x) => x.agent === agent && x.id === l.account); const k = identity(a, box, l.account); const label = a?.email ?? (l.account === "default" ? box : `${box} · ${l.account}`);