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..68adb5c7d --- /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/`, 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) + +- 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=/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. +- `../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..a42316a74 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/claude-usage.sh @@ -0,0 +1,48 @@ +#!/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]} +# 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" -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, +# 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..b551bd840 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/usage-limits-live.mjs @@ -0,0 +1,102 @@ +// 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=/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 +// +// 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(); + 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 + // 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); + 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(); + 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); +} diff --git a/app/src/lib/mock-usage.ts b/app/src/lib/mock-usage.ts index e794a1556..ddb2e173d 100644 --- a/app/src/lib/mock-usage.ts +++ b/app/src/lib/mock-usage.ts @@ -143,12 +143,23 @@ 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) }, 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), + 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 bd1f405d4..66c21b9ee 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -46,6 +46,16 @@ 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. + 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 - **Worktrees with submodules can be removed.** git refuses to remove any 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/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..fbf00f7f8 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,186 @@ 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": "", + "/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 new file mode 100644 index 000000000..387cdfdc0 --- /dev/null +++ b/internal/box/claudelimits.go @@ -0,0 +1,137 @@ +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, 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.Join(home, ".claude"): + return "default" + case d != "" && filepath.Dir(d) == integrations.AccountsDir(home, "claude"): + return filepath.Base(d) + } + return "" +} + +// 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 cea41a3fa..7353e0696 100644 --- a/plugins/usage/README.md +++ b/plugins/usage/README.md @@ -26,10 +26,39 @@ 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 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** 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 writes them nowhere, so the box records them (below). + +### Claude Code's plan limits + +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: + +```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%/} + 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`, 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 88272d250..b039b774e 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 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") + 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, "day": local_day(at), "limits": windows}) + return out + + def report(days): started = time.monotonic() CACHE.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -307,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"), @@ -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/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 10eb6199f..c50b0f7e5 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 { @@ -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 22b0f8d2a..9b406cad0 100644 --- a/plugins/usage/src/usage-view.tsx +++ b/plugins/usage/src/usage-view.tsx @@ -271,13 +271,13 @@ 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) { 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}`); @@ -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" })}`}
); })}