Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/lib/mock-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions app/src/lib/usage-py.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion plugins/usage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
134 changes: 131 additions & 3 deletions plugins/usage/box/usage.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 -------------------------------------------------------------


Expand Down Expand Up @@ -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"),
Expand Down
85 changes: 85 additions & 0 deletions plugins/usage/box/usage_test.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 7 additions & 2 deletions plugins/usage/src/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion plugins/usage/src/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading