diff --git a/app/src/lib/mock-usage.ts b/app/src/lib/mock-usage.ts index e794a155..6ea963d4 100644 --- a/app/src/lib/mock-usage.ts +++ b/app/src/lib/mock-usage.ts @@ -139,6 +139,16 @@ function report(box: string) { s("claude", "default", WT[0], "Reproduce the double-charge bug", 75, 1.6, [40_210, 190_442, 15_002_871, 902_114], 22.6, ["claude-opus-5-5"]), ], limits: [ + { + agent: "claude", + account: "default", + at: at(0.02), + limits: { + primary: { used_percent: 31, window_minutes: 300, resets_at: Math.round(now.getTime() / 1000 + 2 * 3600) }, + secondary: { used_percent: 64, window_minutes: 10080, resets_at: Math.round(now.getTime() / 1000 + 3 * 86400) }, + extra: [{ used_percent: 32, window_minutes: 10080, resets_at: Math.round(now.getTime() / 1000 + 3 * 86400), label: "Weekly limit (Fable)" }], + }, + }, { agent: "codex", account: "default", diff --git a/app/src/lib/usage-py.test.ts b/app/src/lib/usage-py.test.ts new file mode 100644 index 00000000..f1a01309 --- /dev/null +++ b/app/src/lib/usage-py.test.ts @@ -0,0 +1,15 @@ +// Runs the Usage plugin's box-side tests (plugins/usage/box/usage_test.py) +// with the app's, so CI checks usage.py too. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const box = join(dirname(fileURLToPath(import.meta.url)), "../../../plugins/usage/box"); +const python = spawnSync("python3", ["--version"]).status === 0; + +test("usage.py reads Claude Code's /usage", { skip: !python && "no python3" }, () => { + const r = spawnSync("python3", ["-m", "unittest", "-q", "usage_test.py"], { cwd: box, encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr || r.stdout); +}); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index e7fefed8..0eef1470 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,6 +9,14 @@ upgrades with `berth upgrade BOX`, or by running the [install command](/getting-started/add-a-box#with-the-install-command) again. +## Unreleased + +- **Usage shows Claude Code's plan limits.** The Usage screen and its home + widget now show each Claude login's current session, its week and any week + for one model (such as Fable), with when each resets — what Claude Code's + own `/usage` prints. The box asks `/usage` at most every five minutes per + login, in a way that leaves no transcript and starts no MCP server. + ## v0.3.21 - **The phone app pairs from a pasted link.** Its Pair and Boxes screens have diff --git a/plugins/usage/README.md b/plugins/usage/README.md index cea41a3f..cec09e02 100644 --- a/plugins/usage/README.md +++ b/plugins/usage/README.md @@ -29,7 +29,9 @@ The numbers come from the files each agent writes on the box. Nothing is estimat - **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** come from the agents themselves. A window that has reset since says so instead of showing an old percentage. + - **Codex** writes its 5-hour and weekly windows into its transcripts; they show as "as of" the last turn Codex took. + - **Claude Code** writes its limits nowhere, but its own `/usage` prints them. For each signed-in account the box runs `claude --print --no-session-persistence --strict-mcp-config "/usage"` (with that account's `CLAUDE_CONFIG_DIR`) from `~/.cache/berth-usage/claude-usage`, at most once every 5 minutes per account (`BERTH_USAGE_CLAUDE_LIMITS_TTL`, in seconds), and shows the session, the week and any week scoped to one model. That flag set asks no trust question, writes no transcript and starts no MCP server; each read is one request to Anthropic, as `/usage` makes. `BERTH_USAGE_CLAUDE_LIMITS=0` turns it off. A login without a subscription prints no limits and shows none. (The approach is Codenotch's, MIT.) ### How it runs on the box diff --git a/plugins/usage/box/usage.py b/plugins/usage/box/usage.py index 88272d25..8bcb854b 100644 --- a/plugins/usage/box/usage.py +++ b/plugins/usage/box/usage.py @@ -1,6 +1,7 @@ """Usage and accounts for the Usage plugin, run on a box through exec. - usage.py report [DAYS] token usage from Claude Code and Codex transcripts + usage.py report [DAYS] token usage from Claude Code and Codex transcripts, + and their plan limits (Claude Code's from /usage) usage.py accounts logins per agent, and which one each session uses usage.py mkaccount AGENT NAME an empty account folder to sign in to @@ -143,6 +144,130 @@ def mkaccount(agent, name): return {"agent": agent, "id": name, "dir": str(d)} +# --- Claude Code's plan limits ----------------------------------------------- + +# Claude Code writes its limits nowhere on disk, but its own `/usage` prints +# them, off the login the CLI already holds: +# +# Current session: 31% used · resets Oct 15 at 3:30am (Asia/Saigon) +# Current week (all models): 64% used · resets Oct 19 at 8am (Asia/Saigon) +# Current week (Fable): 32% used · resets Oct 19 at 8am (Asia/Saigon) +# +# --print skips the workspace-trust question, --no-session-persistence the +# transcript, --strict-mcp-config every MCP server. Run from one folder of its +# own, so Claude Code files no project for each read. (After Codenotch's +# ClaudeUsageCLI, MIT.) +CLAUDE_LIMITS_CACHE = HOME / ".cache" / "berth-usage" / "claude-limits.json" +CLAUDE_LIMITS_TTL = float(os.environ.get("BERTH_USAGE_CLAUDE_LIMITS_TTL", "300")) +CLAUDE_USAGE_ARGS = ["--print", "--no-session-persistence", "--strict-mcp-config", "/usage"] +LIMIT_LINE = re.compile(r"^Current (?:(session)|week \(([^)]+)\)):\s*(\d+(?:\.\d+)?)%\s*used(?:\s*·\s*resets\s*(.+?))?\s*$", re.M) +MONTHS = {m: i for i, m in enumerate(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)} +RESET = re.compile(r"^([A-Za-z]{3})[a-z]*\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)$", re.I) + + +def reset_epoch(text, now=None): + """`Oct 19 at 8am (Asia/Saigon)` as Unix seconds, or None. No year is + printed: the candidate nearest now, over last, this and next year, is + taken, so a window read across New Year lands in the right one.""" + now = now or dt.datetime.now(dt.timezone.utc) + text = text.strip() + zone = None + if text.endswith(")") and "(" in text: + name = text[text.rindex("(") + 1:-1] + text = text[:text.rindex("(")].strip() + try: + from zoneinfo import ZoneInfo + zone = ZoneInfo(name) + except Exception: + zone = None + m = RESET.match(text) + if not m or m.group(1).lower() not in MONTHS: + return None + month, day, hour, minute = MONTHS[m.group(1).lower()], int(m.group(2)), int(m.group(3)) % 12, int(m.group(4) or 0) + if m.group(5).lower() == "pm": + hour += 12 + zone = zone or dt.datetime.now().astimezone().tzinfo + best = None + for year in (now.year - 1, now.year, now.year + 1): + try: + t = dt.datetime(year, month, day, hour, minute, tzinfo=zone) + except ValueError: + continue + if best is None or abs((t - now).total_seconds()) < abs((best - now).total_seconds()): + best = t + return int(best.timestamp()) if best else None + + +def parse_claude_usage(text, now=None): + """`/usage`'s limit lines as the plugin's limits: the session window + first, the all-models week second, other weeks (one model's) after. None + without a session line: a reading with a hole in it is not shown.""" + windows = {} + for m in LIMIT_LINE.finditer(text or ""): + kind = "session" if m.group(1) else ("week" if m.group(2).lower() == "all models" else "week:" + m.group(2)) + if kind in windows: + continue + w = {"used_percent": float(m.group(3)), "window_minutes": 300 if kind == "session" else 7 * 24 * 60, + "resets_at": reset_epoch(m.group(4), now) if m.group(4) else None} + if kind.startswith("week:"): + w["label"] = f"Weekly limit ({m.group(2)})" + windows[kind] = w + if "session" not in windows: + return None + return {"primary": windows.pop("session"), "secondary": windows.pop("week", None), "extra": list(windows.values())} + + +def claude_binary(): + import shutil + return shutil.which("claude") or next((str(p) for p in [HOME / ".local" / "bin" / "claude", HOME / ".claude" / "local" / "claude"] if p.exists()), None) + + +def claude_usage_text(name, d, binary): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + if name != "default": + env["CLAUDE_CONFIG_DIR"] = str(d) + cwd = CLAUDE_LIMITS_CACHE.parent / "claude-usage" + cwd.mkdir(parents=True, exist_ok=True, mode=0o700) + r = subprocess.run([binary, *CLAUDE_USAGE_ARGS], cwd=cwd, env=env, capture_output=True, text=True, timeout=25, stdin=subprocess.DEVNULL) + return r.stdout + + +def claude_limits(): + """Each signed-in Claude login's limits, read with `/usage` at most once + every CLAUDE_LIMITS_TTL seconds per login. Off with + BERTH_USAGE_CLAUDE_LIMITS=0.""" + if os.environ.get("BERTH_USAGE_CLAUDE_LIMITS") == "0": + return [] + binary = claude_binary() + if not binary: + return [] + cache = read_json(CLAUDE_LIMITS_CACHE) or {} + out, changed = [], False + for name, d in claude_dirs(): + if not claude_account(name, d)["signed_in"]: + continue + hit = cache.get(name) + if not hit or time.time() - hit.get("read", 0) > CLAUDE_LIMITS_TTL: + try: + limits = parse_claude_usage(claude_usage_text(name, d, binary)) + except (OSError, subprocess.SubprocessError): + limits = None + now = dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + # A read that failed keeps the last good one, as of when it was. + hit = {"read": time.time(), "at": now, "limits": limits} if limits else {**(hit or {}), "read": time.time()} + cache[name], changed = hit, True + if hit.get("limits"): + out.append({"agent": "claude", "account": name, "at": hit["at"], "limits": hit["limits"]}) + if changed: + tmp = CLAUDE_LIMITS_CACHE.with_name(f"claude-limits.{os.getpid()}.tmp") + try: + tmp.write_text(json.dumps(cache)) + tmp.replace(CLAUDE_LIMITS_CACHE) + except OSError: + tmp.unlink(missing_ok=True) + return out + + # --- transcripts ------------------------------------------------------------- @@ -306,8 +431,11 @@ def report(days): for m in d: 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"]} + # Keyed by agent too: Claude Code and Codex each have a "default". + if s.get("limits") and ((agent, account) not in limits or s["limits_at"] > limits[(agent, account)]["at"]): + limits[(agent, account)] = {"agent": agent, "account": account, "at": s["limits_at"], "limits": s["limits"]} + for l in claude_limits(): + limits[("claude", l["account"])] = l out = { "generated": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"), diff --git a/plugins/usage/box/usage_test.py b/plugins/usage/box/usage_test.py new file mode 100644 index 00000000..0b3ebc8d --- /dev/null +++ b/plugins/usage/box/usage_test.py @@ -0,0 +1,85 @@ +"""Tests for usage.py's reading of Claude Code's `/usage`. + + python3 -m unittest plugins/usage/box/usage_test.py +""" +import datetime as dt +import importlib.util +import os +import pathlib +import unittest + +spec = importlib.util.spec_from_file_location("usage", pathlib.Path(__file__).with_name("usage.py")) +usage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(usage) + +# What Claude Code 2.1.296 printed, word for word. +SAMPLE = """You are currently using your subscription to power your Claude Code usage + +Current session: 31% used · resets Oct 15 at 3:30am (Asia/Saigon) +Current week (all models): 64% used · resets Oct 19 at 8am (Asia/Saigon) +Current week (Fable): 32% used · resets Oct 19 at 8am (Asia/Saigon) + +What's contributing to your limits usage? +Last 24h · 318 requests · 5 sessions + 100% of your usage was at >150k context +""" + +NOW = dt.datetime(2026, 10, 14, 12, 0, tzinfo=dt.timezone.utc) + + +def at(y, mo, d, h, mi, zone="Asia/Saigon"): + from zoneinfo import ZoneInfo + return int(dt.datetime(y, mo, d, h, mi, tzinfo=ZoneInfo(zone)).timestamp()) + + +class ClaudeUsageTest(unittest.TestCase): + def test_reads_the_session_the_week_and_a_models_week(self): + l = usage.parse_claude_usage(SAMPLE, NOW) + self.assertEqual(l["primary"], {"used_percent": 31.0, "window_minutes": 300, "resets_at": at(2026, 10, 15, 3, 30)}) + self.assertEqual(l["secondary"], {"used_percent": 64.0, "window_minutes": 10080, "resets_at": at(2026, 10, 19, 8, 0)}) + self.assertEqual(l["extra"], [{"used_percent": 32.0, "window_minutes": 10080, "resets_at": at(2026, 10, 19, 8, 0), "label": "Weekly limit (Fable)"}]) + + def test_the_prose_below_is_no_limit(self): + # "100% of your usage..." is about what drove the usage, not a window. + l = usage.parse_claude_usage(SAMPLE, NOW) + self.assertEqual(len(l["extra"]), 1) + + def test_no_session_line_means_no_reading(self): + self.assertIsNone(usage.parse_claude_usage("Current week (all models): 4% used · resets Sep 14 at 5:59am (Asia/Jakarta)\n", NOW)) + self.assertIsNone(usage.parse_claude_usage("Usage is only available for subscription plans.\n", NOW)) + self.assertIsNone(usage.parse_claude_usage("", NOW)) + + def test_a_reset_it_cannot_read_keeps_the_percentage(self): + l = usage.parse_claude_usage("Current session: 38% used · resets in a little while\n", NOW) + self.assertEqual(l["primary"]["used_percent"], 38.0) + self.assertIsNone(l["primary"]["resets_at"]) + + def test_resets_with_and_without_minutes(self): + self.assertEqual(usage.reset_epoch("Sep 7 at 2:59pm (Asia/Jakarta)", dt.datetime(2026, 9, 7, tzinfo=dt.timezone.utc)), at(2026, 9, 7, 14, 59, "Asia/Jakarta")) + self.assertEqual(usage.reset_epoch("Sep 7 at 3pm (Asia/Jakarta)", dt.datetime(2026, 9, 7, tzinfo=dt.timezone.utc)), at(2026, 9, 7, 15, 0, "Asia/Jakarta")) + self.assertEqual(usage.reset_epoch("Sep 7 at 12am (Asia/Jakarta)", dt.datetime(2026, 9, 7, tzinfo=dt.timezone.utc)), at(2026, 9, 7, 0, 0, "Asia/Jakarta")) + self.assertEqual(usage.reset_epoch("Sep 7 at 12pm (Asia/Jakarta)", dt.datetime(2026, 9, 7, tzinfo=dt.timezone.utc)), at(2026, 9, 7, 12, 0, "Asia/Jakarta")) + + def test_a_zone_with_am_in_its_name(self): + self.assertEqual(usage.reset_epoch("Oct 19 at 8am (America/Panama)", NOW), at(2026, 10, 19, 8, 0, "America/Panama")) + + def test_the_year_nearest_now_across_new_year(self): + eve = dt.datetime(2026, 12, 31, 12, tzinfo=dt.timezone.utc) + self.assertEqual(usage.reset_epoch("Jan 2 at 9am (UTC)", eve), at(2027, 1, 2, 9, 0, "UTC")) + new_year = dt.datetime(2027, 1, 2, 12, tzinfo=dt.timezone.utc) + self.assertEqual(usage.reset_epoch("Dec 31 at 9am (UTC)", new_year), at(2026, 12, 31, 9, 0, "UTC")) + + def test_nonsense_is_none(self): + self.assertIsNone(usage.reset_epoch("Smarch 3 at 9am (UTC)", NOW)) + self.assertIsNone(usage.reset_epoch("tomorrow", NOW)) + + def test_off_reads_nothing(self): + os.environ["BERTH_USAGE_CLAUDE_LIMITS"] = "0" + try: + self.assertEqual(usage.claude_limits(), []) + finally: + del os.environ["BERTH_USAGE_CLAUDE_LIMITS"] + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/usage/src/box.ts b/plugins/usage/src/box.ts index 73d48108..c64e7101 100644 --- a/plugins/usage/src/box.ts +++ b/plugins/usage/src/box.ts @@ -42,14 +42,19 @@ export interface UsageSession { export interface Window { used_percent: number; window_minutes: number; - resets_at: number; + // Unix seconds; null when the agent printed a reset it couldn't be read from. + resets_at: number | null; + // The window's own name, for one scoped to a model ("Weekly limit (Fable)"). + label?: string; } export interface Limits { agent: Agent; account: string; at: string; - limits: { plan_type?: string | null; primary?: Window | null; secondary?: Window | null }; + // Codex reports a 5-hour and a weekly window in its transcripts; Claude Code + // reports its session, its week and a week per scoped model in `/usage`. + limits: { plan_type?: string | null; primary?: Window | null; secondary?: Window | null; extra?: Window[] }; } export interface Account { diff --git a/plugins/usage/src/home.tsx b/plugins/usage/src/home.tsx index 10eb6199..e022c757 100644 --- a/plugins/usage/src/home.tsx +++ b/plugins/usage/src/home.tsx @@ -51,7 +51,7 @@ export function summarizeUsage(reports: { box: string; report: Report }[], faile if (!newest.has(k) || newest.get(k)!.at < l.at) newest.set(k, l); } for (const l of newest.values()) - for (const w of [l.limits.primary, l.limits.secondary]) if (w) limits.push({ label: `${AGENT_NAME[l.agent]} · ${windowName(w)}`, used: w.used_percent / 100, resets: w.resets_at * 1000 }); + for (const w of [l.limits.primary, l.limits.secondary, ...(l.limits.extra ?? [])]) if (w?.resets_at) limits.push({ label: `${AGENT_NAME[l.agent]} · ${w.label ?? windowName(w)}`, used: w.used_percent / 100, resets: w.resets_at * 1000 }); } return { today: at.get(today)?.tokens ?? 0, diff --git a/plugins/usage/src/usage-view.tsx b/plugins/usage/src/usage-view.tsx index 22b0f8d2..8d976231 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 each login reported. +// Limits read 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,30 +390,33 @@ 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: Codex's is what it saw on its last turn, Claude +// Code's what its `/usage` said when the box was last read. function LimitRows({ shared }: { shared: SharedLimits }) { const l = shared.limits; - const windows = [l.limits.primary, l.limits.secondary].filter(Boolean) as Window[]; + const windows = [l.limits.primary, l.limits.secondary, ...(l.limits.extra ?? [])].filter(Boolean) as Window[]; + const next = l.agent === "codex" ? "Codex reports the new window on its next turn." : "The next read shows the new window."; return (
{shared.label &&
{shared.label}
} {windows.map((w, i) => { - const reset = new Date(w.resets_at * 1000); - const past = reset.getTime() < Date.now(); + const reset = w.resets_at ? new Date(w.resets_at * 1000) : null; + const past = reset !== null && reset.getTime() < Date.now(); + const name = w.label ?? windowName(w); return (
- {windowName(w)} + {name} as of {ago(l.at)} {past ? "reset since" : `${Math.round(w.used_percent)}% used`}
{!past && ( -
+
= 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" })}`}
+
{!reset ? "" : past ? `It reset ${reset.toLocaleDateString()}; ${next}` : `Resets ${reset.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}`}
); })}