How it works

Pace, rebuilt: reading Claude Code limits without touching a single credential

How Pace v2 tracks Claude Pro/Max usage from Claude Code's own /usage command and status line, turns local transcripts into a live burn rate, tells you whether to speed up or ease off, and decides when to warn you.

Pace is a small macOS menu bar app with one job: tell you whether your current pace in Claude Code will run you into your plan limit before it resets, and if so, when.

The first version worked. It also read the sessionKey cookie out of your browser's encrypted cookie database, decrypted it with a key from the Keychain, and called claude.ai's private usage endpoint with it. That is a credential-handling app pretending to be a gauge. Anthropic's terms are clear that third-party tools shouldn't collect, store or relay Claude.ai session credentials, and the same goes for routing requests through someone's Pro/Max OAuth token. So v2 started with a constraint, not a feature list:

Pace must never see a password, cookie, API key or OAuth token, and nothing it reads may leave the Mac.

Everything below follows from that.

1. Where the numbers come from

There are three local sources. None of them is a credential.

A. claude -p "/usage" — the authoritative reading

Claude Code has a built-in /usage command. It is a local command: Claude Code handles it itself and never sends it to a model. Run non-interactively it looks like this:

claude -p "/usage" --safe-mode --no-session-persistence --output-format json

On my machine that returns in about 2.6 s with "num_turns": 0 and "total_cost_usd": 0. Claude Code does its own authenticated request; Pace only reads stdout. The result field is the same text you see interactively:

Current session: 2% used · resets Oct 12 at 7:10pm (Australia/Sydney)
Current week (all models): 48% used · resets Oct 14 at 5pm (Australia/Sydney)
Current week (Fable): 0% used · resets Oct 14 at 5pm (Australia/Sydney)

Last 24h · 1957 requests · 10 sessions
  62% of your usage was at >150k context
  62% of your usage came from sessions active for 8+ hours
  28% of your usage came from subagent-heavy sessions
  Top subagents: general-purpose 9%
  …

A few details took experiments to get right:

  • --safe-mode keeps the probe from loading hooks, MCP servers or plugins. --bare looks tempting but skips reading the stored login entirely, so it can't report usage.
  • --no-session-persistence plus a clean environment means the probe leaves no transcript behind. An early test seemed to prove otherwise; the culprit was an inherited CLAUDE_CODE_CHILD_SESSION variable. Pace now builds the child environment from scratch: HOME, USER, TMPDIR, PATH, LANG=en_US.UTF-8, NO_COLOR=1, and nothing else.
  • Working directory. An interactive claude in a fresh folder stops at the "trust this folder?" prompt. -p mode doesn't, but Pace still runs the probe in its own empty directory under Application Support so it never picks up a project's settings.
  • Parsing human text. Reset times arrive as resets Oct 13 at 12:09am (Australia/Sydney), sometimes without a date (resets 7pm) and sometimes without minutes. The parser resolves them in the stated time zone, infers the year (rolling over if the date is more than two days in the past) and rolls a time-only reset to tomorrow if it has already passed. The real output is checked in as a test fixture.

The cost of this source is latency and cadence: about 2.6 s per reading, so Pace runs it every few minutes (5 by default), with exponential backoff when it fails.

B. The status line — real time, for free

Claude Code renders a status line by piping a JSON blob to a command you configure. For Pro/Max subscribers that blob includes:

"rate_limits": {
  "five_hour": { "used_percentage": 41, "resets_at": 1791810540 },
  "seven_day": { "used_percentage": 57, "resets_at": 1791957540 }
}

It updates after every response, which is exactly when usage changes. Pace ships a tiny helper, pace-bridge, that you can put in front of your existing status line:

Claude Code ──stdin──▶ pace-bridge --chain ──stdin──▶ your status line command
                          │
                          └─▶ ~/Library/Application Support/Pace/statusline.json
The relay: Claude Code’s status line input passes through pace-bridge unchanged, and a copy of the snapshot lands where Pace can read it.

The bridge writes the snapshot atomically, then runs your original command with the same stdin and passes its output and exit code through, so your status line looks exactly the same. On this machine the added time is not measurable. Connecting from Settings:

  • edits only the statusLine.command key of ~/.claude/settings.json, keeping every other key;
  • records your original command so Disconnect puts it back exactly;
  • refuses to touch a settings file that isn't valid JSON, and keeps a one-time backup.

The catch: rate_limits only appears for subscribers and only after the first response of a session, and there is no per-model week. So the status line is the fast path and /usage stays the source of truth.

One surprise from real use: with several Claude Code sessions open, each one reports the rate_limits it saw at its own last response. An idle session keeps sending its older, lower number, and the reading flapped between 8% and 9% on every update. Usage within one window can only go up, so Pace treats a lower reading of the same window (same reset time) as stale: it refreshes the timestamp and keeps the higher value.

C. Local transcripts — what's actually burning

Claude Code writes every session to ~/.claude/projects/**/*.jsonl. Assistant lines carry message.usage (input, output, cache read, cache write by TTL), the model, a timestamp and the working directory. None of that is a limit percentage, but it tells you when tokens were spent and where.

The volume is real: my last seven days are about 2 GB of JSONL. The scanner:

  • keeps a byte cursor per file and only reads what was appended since last time;
  • reads in 4 MB chunks, finds line breaks with memchr, and uses memmem to skip any line that doesn't contain both "type":"assistant" and "usage" before paying for JSON decoding;
  • only consumes complete lines, so a half-written line is picked up next pass;
  • dedupes on message.id + requestId (streamed responses log the same message several times with growing counts) and keeps the largest.

The first scan of a week takes about 3.5 s in a release build (around 12,000 events); after that, an incremental pass takes about 12 ms. It runs every 30 seconds.

To compare tokens across kinds and models, Pace uses a weighted proxy that follows relative API pricing: input 1, output 5, cache read 0.1, cache write 1.25 (5-minute) or 2 (1-hour), times a model-family factor (Haiku ⅓, Sonnet 1, larger models 5⁄3). It doesn't need to match Anthropic's internal accounting. It only needs to be proportional within a window, because of the calibration trick below.

2. Turning readings into a forecast

For each window (session, week, week per model) Pace computes:

QuantityMeaning
even pacewhere you'd be if you spent evenly: elapsed / duration × 100
burn ratepercentage points per hour, right now
projectedused + rate × hours to reset
time to limit(100 − used) / rate, if that's before the reset
sustainable rate(100 − used) / hours to reset: spend this and land on exactly 100%
How the popover draws each window: the solid fill is used, the tick is even pace, the faint fill is the projection at reset.

The interesting part is the burn rate. A naive "average since the window started" is wrong in both directions: it lags when you speed up and cries wolf after an early burst (5% in the first ten minutes projects to 150%). Pace picks the most trustworthy estimate available:

  1. Calibrated local activity (session only). Divide the session's observed percentage by the weighted tokens spent locally since the window started. That gives percent per unit of weight for this window, on this account. Multiply it by the weight of the last 20 minutes. Because the factor is re-derived from the live reading every time, errors in the weighting cancel out. Pace requires at least 3% used and some local activity before trusting it. This is what makes the rate react within ~30 seconds of starting or stopping a heavy agent, instead of waiting for the next reading.
  2. Recent slope. A least-squares fit over recent readings of the same window instance: the last 45 minutes for the session (at least 8 minutes of span), the last 24 hours for the week (at least 6 hours). A one-hour burst extrapolated across five remaining days would alarm constantly, hence the longer horizon.
  3. Window average, but only once 15% of the window has elapsed.
  4. Otherwise, no rate. Pace says nothing rather than guessing.

Readings are stored in a small JSONL history (8 days), keyed by window and reset time, so a reset never contaminates a slope. The status line fires after every response, so an unchanged value is only written again as a 10-minute heartbeat, which keeps flat stretches flat without filling the file.

The dashboard shows the rate over several horizons side by side (now, 1 h, 5 h, 24 h and the window average) against the sustainable rate, so a short burst and a long habit are easy to tell apart.

3. Speed up or ease off

Status answers "will I run out?". The question you actually act on is "should I go faster or slower?". For every window Pace computes one number:

factor = sustainable rate / current rate

A factor of 2 means you could go twice as fast and still land on 100% exactly at the reset; 0.5 means halve your pace. Idle is unlimited headroom; at the limit it is 0.

  • Ease off below 1. That is exactly the at risk condition, so the arrow and the status colour can't disagree.
  • On pace from 1 to 1.25. Normal work keeps the arrow still.
  • Speed up from 1.25.

Session or week? Both windows constrain you at once, and you can only go as fast as the tightest one allows. So the direction follows the window with the smallest factor. During a burst that is usually the 5-hour session; when your daily habit is too heavy it is the week, even while the session looks fine. To stop the display flickering when two windows are close, the binding window only changes when another one is at least 10% tighter. You can also pin the direction to the session or the week in Settings.

Drawing it. The arrow's angle follows the factor on a log scale (±60° at 4× or ¼×), with one twist. Plotted plainly, ×0.88 tilts only about 5°, which reads as flat at 16 pt even though that window is already at risk. So the arrow is flat inside the on-pace band and jumps to ±20° the moment it leaves it, the smallest tilt where the arrowhead's two arms visibly differ at menu bar size.

Speed upFactor 1.25 or more: there's room to go faster.
On paceBetween 1 and 1.25: the arrow stays level.
Ease offBelow 1, exactly the at risk condition.
LimitedAt the limit: a stop square until the reset.

Session (5h)A continuous ring: the session is the tight one.

Week (7d)Seven segments, one per day: the week is.

The Day Ring: the arrow at speed up (×1.5), on pace, ease off (×0.68) and limited. A continuous ring follows the session; seven segments, the week.

4. One status scale, everywhere

Every window gets one of four states, and the menu bar colour, the cards and notifications all derive from the same value, so they can't disagree:

  • Comfortable: projected below 85% at reset.
  • Watch: projected to end at 85% or more.
  • At risk: at this rate you hit 100% before the reset.
  • Limited: at the limit.

The headline is the worst window. Among windows heading for the limit, the one that gets there soonest leads; otherwise the one projected to end highest. Notifications fire on at risk only when the limit is near enough to act on (under 2 h for the session, under 24 h for the week), once per window instance. You also get one when a limit is reached, and one when a window that was at risk or limited resets.

Comfortable stateWatch stateAt risk stateLimited state
The four states as they appear in the popover’s headline card: comfortable, watch, at risk, limited (rendered from Pace’s demo mode).

5. The app

Pace is a Swift package with three products:

  • PaceCore: parsing, probing, scanning, the forecast engine and storage. No UI and fully unit-tested (Swift Testing, with fixtures from real output).
  • Pace: AppKit owns the NSStatusItem and popover; SwiftUI draws everything inside. @Observable models, Swift 6 language mode with strict concurrency.
  • pace-bridge: the status line helper, bundled in Pace.app/Contents/Helpers.

The menu bar glyph, the Day Ring, is drawn in code as a template image. Its arc is how much of the binding window is used and the arrow inside is the pace direction. A continuous ring means the session is the binding window; a ring cut into seven segments, one per day, means the week is. It only takes colour when something needs your attention, and a usage-only or arrow-only style is one setting away.

Clicking through to the dashboard (⌘D) shows everything in full: every window with its burn rate across horizons, usage over the window against the even-pace line and the projection, local activity by project and model, and what Claude Code's /usage says is driving your usage.

Pace dashboard in the at-risk state, from demo mode
The dashboard (⌘D) in demo mode: burn rate by horizon against the sustainable rate, and usage over the window with the projection.

6. What Pace doesn't do

  • It doesn't read browser cookies, the Keychain, or Claude Code's stored login.
  • It doesn't call any Anthropic endpoint itself. Claude Code does, as it would anyway.
  • It doesn't send anything anywhere. No analytics, no update pings.
  • It isn't made by or affiliated with Anthropic.

If Claude Code changes the wording of /usage so that no window lines parse, Pace reports "unrecognised output" instead of showing stale or invented numbers, and the status line path keeps working in the meantime. The real output is a fixture in the test suite, so a format change becomes one failing test and one parser fix.

Pace v0.6.0 · macOS 14 or later Download Pace