Skip to content

feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) - #699

Merged
sirmalloc merged 27 commits into
sirmalloc:mainfrom
eric-engberg:feat/session-forecast
Oct 29, 2026
Merged

sirmalloc merged 27 commits into
sirmalloc:mainfrom
eric-engberg:feat/session-forecast

Conversation

@eric-engberg

@eric-engberg eric-engberg commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

Two new Usage widgets forecast the 5-hour block from the recent pace:

  • Block Forecast shows where the block's usage will be when it resets: →83.2%.
  • Block Limit Timer shows how long until 100% at that pace, Limit in: 1hr 13m, but only when that comes before the reset.

Each renders nothing until it has something to say, so they sit right after Block Usage. As separate widgets:

What's happening Line
Under pace 5h: 42.0% →83.2%
On pace to hit 100% before the reset 5h: 61.0% →100.0% Limit in: 1hr 13m
At 100% 5h: 100.0%
Idle, or a block less than 10 minutes old 5h: 42.0%

Block Forecast has u (show remaining, like Block Usage: →16.8%), and Block Limit Timer has s (short time: 1h13m). Both support raw value mode and their own colors; Block Limit Timer defaults to red. Pair them with Block Reset Timer's under-limit hide state (#697) to show the reset time once the limit is hit.

They're named for the block, like Block Timer and Block Reset Timer, rather than the session: ccstatusline also says "session" for the Claude Code conversation (Session Clock, Session Cost, Session Name).

Why

Block Usage says how much of the 5-hour limit is used, not whether you'll run out. 42% two hours in is fine; 42% twenty minutes in means you'll hit the limit well before the block resets. The forecast answers that directly, and the limit timer says how much working time is left if you keep going at this pace.

How

  • Pace: the change in the block's percent over the last 30 minutes, projected to the reset: current + pace × time left, capped at 100%. The limit time is (100 − current) ÷ pace, shown only when it's shorter than the time left. Nothing is shown when the pace is zero or negative (idle), already at 100%, past the reset, or in a block's first 10 minutes.
  • History: the percent from 30 minutes ago comes from a small history file, ~/.cache/ccstatusline/session-usage-history.json. Each 5-hour window keeps a list of readings, [first seen, last seen, percent]. A reading is added when the percent rises; while it doesn't, its last-seen time moves forward at most once a minute, so an active session writes about once a minute and an idle one not at all. A lower percent is ignored: usage never falls within a window, so it can only be another session's stale payload (each session reports the limits from its own last response), and recording it would fake a steep climb half an hour later.
  • Gaps: status lines only render while Claude Code is working, so there can be long stretches with no readings (the machine asleep, or usage on claude.ai or another computer). The value 30 minutes ago is interpolated between the readings either side of it, so a climb during a gap is spread over the gap instead of being treated as "unchanged until the next reading", which would overstate the pace right after it. Before the first reading, the line runs from the window's start at 0% (every window starts there), which gives the whole-window average.
  • Windows and accounts: readings are kept per Claude config directory (CLAUDE_CONFIG_DIR, or ~/.claude), since different logins have different windows. A reading joins the stored window whose reset time is within an hour of its own: one window's reset time jitters between sources (the usage API sends fractional seconds that change between fetches, the payload whole seconds), and different windows are always at least 5 hours apart. Windows past their reset are dropped, and each keeps only the readings the 30-minute lookback can still use.
  • Once per render: the reading is recorded and the forecast computed once, before the widgets render, and only when a line contains one of the two widgets; both read the result from the render context. The data is the same sessionUsage / sessionResetAt Block Usage uses (the payload's rate_limits, or the usage API when the payload lacks them). A failed fetch made only for these widgets isn't shown as an error elsewhere on the line, since they render nothing without data.
  • Failures: a missing, corrupt or other-version history file is an empty history. Writes go to a temp file and are renamed into place; a failed write is ignored and that render still gets its forecast. There's no lock: two sessions writing at once can lose one update, which only nudges the pace.
  • Preview: Block Forecast shows →100.0% and Block Limit Timer Limit in: 1hr 13m, a consistent "on pace for the limit" sample. Only a real status-line render records a reading; browsing the TUI never touches the history.

Update: Stop the forecast history's temp files piling up; Forecast an idle session's block from the latest reading; Keep another login's 5-hour block apart in the forecast history; Stop an idle session's refresh renders from flattening the forecast; Keep a carried-over bar off the forecast widgets' editor rows; Date a cached usage API percent by when the API sent it; Lock the forecast history while a render rewrites it; Keep each login's 5-hour block apart in the forecast history; Drop the docs' advice against merging Block Usage into the forecast

  • Refresh renders in an idle session: Claude Code re-renders an idle session's status line with the 5-hour percent from its last response. It does this every 10 seconds on a default install, and again when a prompt is written. That unchanged percent used to move its reading's last-seen time to now. So when usage elsewhere (claude.ai, another machine) raised the percent meanwhile, the next response put the whole climb in the last 30 minutes. For example, after 100 idle minutes at 20%, a response at 60% gave Limit in: 30m instead of 1hr 40m. Now a payload's unchanged percent moves the last-seen time only up to the time of the transcript's last API response. That is its last assistant entry, skipping API-error entries and the <synthetic> entries Claude Code writes itself. The transcript's write time wouldn't work, because a prompt, a tool result or a local command's output is written before the response that brings a new percent. A transcript with no response yet, such as a new one after /clear, leaves the reading's last sighting where it was. A percent from the usage API, used when the payload has none, is current and still counts as seen now. The transcript is read backwards from its end, and only when the percent is unchanged and its last sighting is a minute old. The remaining gaps understate rather than overstate. While only a subagent or a background call is responding, no new response reaches the main transcript, so an unchanged percent doesn't extend. A changed percent still records at once.
  • Idle sessions behind the shared history: sessions of one config directory share the history, but an idle session's payload keeps the percent from its own last response. The history already ignored that lower percent. The forecast, though, still placed it at now, after a higher reading another session had recorded. So the rise came out negative or too small, and the idle session showed nothing, or less than the active session, for the same block. Usage never falls within a block, so the forecast now starts from the higher of the session's percent and the latest reading, ignoring readings dated in the future. It returns that percent as currentPercent. Block Forecast now uses that percent, not the session's own, to decide whether a projection only repeats the current value, so both sessions hide the same near-flat projection. Block Usage still shows each session's own percent.
  • Another login in the same config directory: a reading used to join any stored window whose reset was within an hour of its own; this replaces the hour-wide match described above. After /login to another account, that account's block can reset minutes from the first account's. Its readings then landed in the first account's window and, being lower, were dropped. Until the old block reset, Block Forecast and Block Limit Timer showed nothing, then too little once the new account passed the old one's percent. One block's reset differs by under a second between the usage API (fractional seconds) and the payload (whole seconds). So readings now match a window only within a minute, and the other login's block gets a window of its own. Two blocks that reset at exactly the same moment still share a window. So far the usage API's 5-hour resets have all fallen on whole 10-minute marks, so this can happen. Only an account identity in the history's key would tell such blocks apart.
  • Editor rows after Change Widget Type: a type change keeps the widget's metadata. A Block Usage or reset timer in a bar mode could become a Block Limit Timer whose row read (long bar, inverted) while it rendered Limit in: 1hr 13m, or a Block Forecast whose row read (long bar, remaining). Neither widget draws a bar or offers (p) to clear one. The bar mode also hid compact on Block Limit Timer, which still applied it. Both widgets now use a shared helper for text-only usage widgets, getUsageTextModifierText. Before naming the modifiers, it drops the bar mode, and also the bar's inversion unless the widget shows used or remaining. The leftover metadata stays in the saved config, and rendering already ignores it.
  • Temp files on a failed write: The 5-hour usage history is still saved by writing session-usage-history.json.<pid>.tmp and renaming it over session-usage-history.json. Each render keeps its own temp name, so two renders writing at the same time never write into the same file. When the write or the rename fails, for example with EPERM while a Windows virus scanner or sync client holds the new file, the render now unlinks its temp file. A failed unlink is ignored, and the status line renders either way. The handle that broke the rename can block that unlink too, and earlier builds have already left orphans in ~/.cache/ccstatusline. To clear those, each write now lists the cache directory and removes the history's other session-usage-history.json.<digits>.tmp files whose mtime is a minute or more old. Errors from listing, stat or unlink are ignored, and a later write tries again. The one-minute age keeps the sweep away from a temp file that another render is still writing. While a handle stays held, only temp files from the last minute remain, and older ones go on the next write. The listing, stat and unlink calls are injected deps (readdirSync, statSync, unlinkSync), like the module's other file calls, so tests run against an in-memory directory. The sweep runs only on renders that actually write the history, about once a minute in an active session, so other renders don't pay for a directory listing.
  • Cached usage API percents: this replaces "A percent from the usage API, used when the payload has none, is current and still counts as seen now" above. fetchUsageData serves its cache: up to three minutes old normally, and during a rate limit or an API failure, whatever the cache holds, which can be hours old. Each render took that percent as seen now and moved the old reading's last sighting forward, which flattened the forecast meanwhile. Once fetching recovered, the climb since then looked like it happened in the last few minutes. fetchUsageData now keeps when the API sent the values it returns: now for a fresh answer, the cache file's write time for a cached one. getUsageFetchedAtMs exposes it, and the history dates an API percent by it, as it dates a payload percent by the transcript's last response. A new reading is now also dated by when its percent was seen rather than when it is first written, and never before the last reading's last sighting.
  • Concurrent writes: this replaces "There's no lock" above. The renders of every session and profile share the history file. A render read it whole, added its reading and renamed a new file over it, so when two renders overlapped, the later rename put back what it had read: the other render's new reading was lost, or a whole profile it had just added. A render with something to write now creates session-usage-history.json.lock exclusively, reads the history again under it, writes, and removes the lock. A render that finds the lock held doesn't wait. It forecasts from what it read and leaves the write to its next render, which records the same percent with the same time, since readings are dated by when their percent was seen. A lock more than 10 seconds old, or dated that far ahead, was left by a render that died holding it, and is taken over. Renders with nothing to write, most of them, don't take the lock.
  • A login whose block resets at the same moment as another's: this replaces "Two blocks that reset at exactly the same moment still share a window" above. Readings are now kept per config directory and login. The login is the usage cache's fingerprint of its refresh token (fix(usage): fingerprint the usage cache by refresh token so a routine token refresh is not read as an account switch #536), which stays the same when Claude Code reissues the access token every few hours, and changes with /login. getUsageLoginFingerprint reads it through the usage widgets' credential lookup, including its 30-second backoff after finding no login. Without an OAuth login (a token from the environment, say), the history goes by the config directory alone, as before. On macOS that is one keychain read per render that has a forecast widget and a 5-hour percent; renders whose percent comes from the payload didn't read the keychain before.
  • Merging Block Usage into the forecast: with fix(renderer): end a merge at a hidden widget that isn't merged onward #698 merged, a merge ends at a hidden widget that isn't merged onward. The docs no longer say to add the forecast widgets separately rather than merging Block Usage into them, and the "Fully configured" layout below works on main as it is.

Demo

Fake data: a seeded reading history and payloads with the reset 1hr 43m away, in four situations.

Powerline

Block Forecast and Block Limit Timer in four situations, Powerline mode

Plain

Block Forecast and Block Limit Timer in four situations, plain mode

Adding both after Block Usage with the picker's search:

Powerline

Adding Block Forecast and Block Limit Timer, Powerline mode

Plain

Adding Block Forecast and Block Limit Timer, plain mode

Fully configured

How the widgets are meant to be used together, with #697 (Block Reset Timer can hide until the 5-hour block hits 100%) and #698 (a merge ends at a hidden widget that isn't merged onward) applied as well:

  • Block Usage merged without padding into Block Forecast, whose label is set to →, so the forecast reads as part of the percent: 5h: 42.0% →83.2%;
  • Block Limit Timer next, which appears only when the limit comes before the reset;
  • Block Reset Timer with "when the 5-hour block is under 100%" checked, which takes over once the limit is hit.

Without #698, merging Block Usage into the forecast isn't advisable: whenever the forecast hides, the merge would carry on to the next widget. The → label works without customizing as well, and only the leading space is added here for the merged layout.

The same four situations, fake data:

Powerline

The fully configured forecast line in four situations, Powerline mode

Plain

The fully configured forecast line in four situations, plain mode

The line editor holding that line:

Powerline

The line editor with the fully configured forecast line, Powerline mode

Plain

The line editor with the fully configured forecast line, plain mode

Update

Block Usage, Block Forecast and Block Limit Timer piped through one session's renders across an idle stretch, with fake data in a scratch HOME. The last response, 100 minutes ago, saw 20%; a refresh render and a new prompt repeat that 20%; then the new response says 60% (second row: 40%). Before, the whole climb counts as the last half hour: Limit in: 30m, and at 40% a false →100.0% | Limit in: 1hr 30m. After, it is spread over the idle stretch: Limit in: 1hr 40m, and →70.0% with no limit time.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Three sessions of one login sharing the 5-hour history, piped with fake data: the active session that took the block from 30% to 50% over 40 minutes, and two idle sessions whose payloads still say 40% and 30%. Before, the idle sessions show an understated →70.0% or nothing at all. After, all three show the active session's →100.0% | Limit in: 1hr 40m (Block Usage still shows each session's own payload percent).

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data after /login switched accounts in one config directory. Login A was last seen at 80% (reset in 1hr). Login B's block resets 40 minutes later and has climbed 1% a minute for 45 minutes, to 55% (second row: 81%). B's earlier renders were replayed at their times through each branch's own history code. Before, B's readings were dropped into A's window: nothing at 55%, and an understated →83.0% at 81%. After, B's own pace shows: →100.0% | Limit in: 45m and →100.0% | Limit in: 19m.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

The line editor starts with two Block Usage widgets drawn as long bars showing what's remaining; the preview uses the widgets' sample data. Change Widget Type turns the first into Block Limit Timer, then (s)hort is toggled, and turns the second into Block Forecast. The preview then shows plain text. Before, the rows still read (long bar, inverted) and (long bar, remaining), and compact doesn't appear. After, they read (compact) and (remaining).

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data after /login switched accounts in one config directory, with both blocks resetting at the same moment. Login A took its block to 100%, then login B climbed 1% a minute for 45 minutes, to 55%. Every earlier render was replayed at its time through each branch's own CLI. Before, B's readings joined A's window, and the forecast went from A's 100%, so nothing shows. After, B's own pace shows: →100.0% | Limit in: 45m.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data, with the 5-hour percent from the usage API (the payload has none). The API said 40% an hour ago, and a rate limit kept every render since, one every 10 minutes, on that cached 40%. Now a fetch says 60%. Every earlier render was replayed at its time through each branch's own CLI. Before, the cached 40% counted as seen until 5 minutes ago, so the climb to 60% came out as the last half hour's: →100.0% | Limit in: 1hr. After, it is spread over the hour since the 40% was fetched: →93.3%.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Testing

  • Forecast math (15 tests): watched continuously, a gap spread evenly, the window-start fallback, a window younger than 30 minutes, exactly and just under 10 minutes old, landing exactly on 100% at the reset, over 100% with the limit time, idle, falling, at 100%, a reset now or past, a reading from a clock that moved back, a last-seen time past now.
  • History file (17 tests): a new reading on a rise, a lower percent from a stale session ignored, the last-seen time moving after a minute and not before, window matching across the API's fractional-second reset, a new window, dropping reset windows and empty profiles, pruning to what the lookback can use, staying bounded when the percent changes every minute for an hour, separate profiles, corrupt / other-version / malformed files, clamping, a failed write.
  • Once-per-render step (11 tests): nothing without a forecast widget, recording for either widget, the config-directory key (with and without a trailing slash), and nothing (no file access) with a missing, unparsable or past reset time, a missing percent, or no usage data.
  • Widgets: every shown and hidden state, raw value, remaining and short-time modes, the number format, previews, keybinds and editor rows; a renderer test showing a hidden Block Forecast between Block Usage and another widget leaves the line exactly as if it weren't there, with a default separator and in Powerline mode; the prefetch requirements.
  • TUI preview: rendering both widgets in the preview never calls the recording function.
  • bun test: 3129 pass, 0 fail. bun run lint passes. The new test files never write to HOME (run with HOME pointed at an empty directory).
  • Under Node (Vitest): the 6 new test files pass, 70 of 70; the 3 changed files pass, 77 of 77.
  • Built CLI under Bun 1.4.2 and Node 26.10.0, with a seeded history in each of the four situations: all runtimes give identical output in plain and Powerline modes; it differs from main only where the new widgets show. The TUI opens and exits cleanly under both.
  • After merging today's main, which renames Session Usage to Block Usage (fix(usage): call Session Usage Block Usage, ending its "Session:" label clash #708): bun test 3242 pass, 0 fail, bun run lint passes, and the built CLI's plain and Powerline renders match main under Bun 1.4.2 and Node 26.10.0. The examples and media above are re-recorded with Block Usage's 5h: label.
  • Countdown label (latest commit: Block Limit Timer prints Limit in: 1hr 13m, which reads as the time until the limit rather than its size): bun test 3129 pass, 0 fail; bun run lint passes; under Node (Vitest) the two changed test files pass, 13 of 13; the built CLI matches main with the baseline configs under Bun and Node. The media is re-recorded for the new label.

Update:

  • New regression tests, each failing before its fix: a refresh render, or a prompt written before its response, no longer moves a reading's last-seen time past the transcript's last response, and a transcript with no response keeps the last sighting; the forecast after an idle stretch spreads the climb over it (limit in 100 minutes, was 30); the forecast starts from the latest reading when the session's percent is behind it (projected 83.2%, was 52.17%); an idle session's forecast matches the active session's (was none); Block Forecast hides a near-flat projection in both sessions (the idle one showed →42.0%); another login's block, resetting minutes from the first's, keeps its own window (its 10% was dropped into the other login's 80% window); the editor rows after a type change from a bar read (compact) and (remaining) (were (long bar, inverted) and (long bar, remaining)); a failed rename removes its temp file (was left behind); a failed removal still returns the readings.
  • Node v26.10.0: Vitest on the six affected test files (session-usage-history, session-forecast-prefetch, session-forecast, renderer-block-forecast, BlockForecast, BlockLimitTimer) passes all 86 tests. A built dist/ccstatusline.js under Node, in a scratch HOME, gave the fixed output for a prompt after an idle stretch and for a new transcript after /clear, at 60% and at 40%. It also showed an active session still moving its reading up to its last response.
  • Piped before/after with Bun in a scratch HOME with fake data (the demo scenarios). After 100 idle minutes at 20%, with a refresh render and a new prompt, a response at 60% went from Limit in: 30m to Limit in: 1hr 40m. At 40% it went from →100.0% | Limit in: 1hr 30m to →70.0%.
  • Idle sessions at 40% and 30%, while another session took the block to 50%, went from →70.0% and nothing to →100.0% | Limit in: 1hr 40m, the same as the active session.
  • After /login to an account whose block resets 40 minutes after the first one's, the new account at 55% went from nothing to →100.0% | Limit in: 45m. At 81% it went from →83.0% to →100.0% | Limit in: 19m.
  • Line editor: two Block Usage widgets drawn as long bars showing what's remaining were changed to Block Limit Timer (then (s)hort) and Block Forecast. Their rows went from (long bar, inverted) and (long bar, remaining) to (compact) and (remaining).
  • bun test: 3263 pass, 0 fail, 9311 expect() calls across 184 files.
  • bun run lint (tsc --noEmit plus ESLint with --max-warnings=0): clean.
  • Under Node, node node_modules/vitest/vitest.mjs run src/utils/__tests__/session-usage-history.test.ts src/utils/__tests__/session-forecast-prefetch.test.ts: 2 files, 46 tests passed.
  • New tests in src/utils/__tests__/session-usage-history.test.ts:
    • Three failed before the sweep was added: 'removes the temp files other renders left a minute or more ago', 'removes old temp files when its own rename fails too' and 'moves on to the next old temp file when one can't be removed'.
    • Two guards pin the edges: 'leaves the temp files alone when it doesn't write' and 'still returns the readings when the cache directory can't be listed'.
    • 'removes its temp file when the rename fails' and 'still returns the readings when the temp file can't be removed either' cover the unlink.
  • Manual check with a scratch HOME and CLAUDE_CONFIG_DIR, using the dist built with the package.json build flags:
    • One render under Bun and one under Node v26 each deleted the old session-usage-history.json.<n>.tmp files.
    • Each kept a fresh one, plus an unrelated daily.json.333.tmp and a plain session-usage-history.json.tmp.
    • Three more renders with the rename blocked by chflags uchg on the history file, standing in for a held Windows handle, left no new temp files.
  • Review fixes (the last three commits), new regression tests, each failing before its fix:
    • A cached usage answer is dated by the cache file's write time, both fresh and served through a rate limit (was now). A fetched answer is dated by when it came, and an error by nothing.
    • A new percent is dated by when it was seen, never before the last reading's last sighting, and now when nothing shows when it was seen. An API percent's time comes from getUsageFetchedAtMs.
    • Under the lock, the history is read again, keeping a write another render made in between (it was lost). While another render holds the lock, the write is left to a later render, which records the same reading. A lock 10 seconds old, or 10 seconds ahead, is taken over. No lock is taken with nothing to write.
    • Readings are filed under the config directory and login, or the directory alone without a login. Another login whose block resets at the same moment keeps its own readings and gets its own forecast (it showed nothing). The login fingerprint stays the same across an access-token refresh and changes with the refresh token. No login lookup happens without a percent to record.
    • Breaking each fix on purpose fails at least one of these tests. The one exception is clearing the fetch time after a failed fetch, which nothing reads within a render.
  • bun test: 3278 pass, 0 fail across 184 files. bun run lint: clean.
  • After merging today's main (feat(usage): Block Reset Timer can hide until the 5-hour block hits 100% #697, fix(renderer): end a merge at a hidden widget that isn't merged onward #698, feat(tui): opt-in dev reload that restarts the TUI in place #700, fix(hooks): tell the user when a save can't add a widget's hooks #703, fix(git-branch): read a jj repo's detached HEAD as no branch #705 and others): bun test 3359 pass, 0 fail across 186 files; bun run lint clean.
  • Under Node 26.10.0 (Vitest): session-usage-history (43) and session-forecast-prefetch (15) pass, new tests included. The two new fetchUsageData probe tests fail there the same way the file's 40 existing probe tests already do: the probe subprocess loads the TypeScript source, whose extensionless imports Node can't resolve. usage-token.test.ts doesn't load under Node before or after these commits (its node:child_process mock has no spawn).
  • Built CLI under Bun 1.4.2 and Node 26.10.0, in a scratch HOME and CLAUDE_CONFIG_DIR with fake credentials and a stub security. A payload percent was filed under the config directory plus the refresh token's fingerprint, dated by the transcript's last response. An API percent served from a two-hour-old cache during a rate limit was dated by the cache's write time. No lock file was left. Eight profiles rendering at once for three rounds under Node: all eight were recorded every round, and no lock was left.
  • The two demos above, recorded on the branch before and after these commits.

…imit Timer

ccstatusline uses "session" for two things: the 5-hour usage window and
the Claude Code conversation (Session Clock, Session Cost, Session Name).
The timers for the 5-hour window already say "block" (Block Timer, Block
Reset Timer), so these two say it too: Session Forecast becomes Block
Forecast (`block-forecast`) and Session Limit Timer becomes Block Limit
Timer (`block-limit-timer`), with their files, classes, descriptions and
docs. Neither has been released, so no saved setting needs migrating.

The helpers that record and forecast the window keep their names
(session-forecast.ts, session-usage-history.ts, the history file): they
follow the usage data's `sessionUsage` field, which isn't user-facing.
@eric-engberg eric-engberg changed the title feat(usage): forecast the 5-hour window (Session Forecast, Session Limit Timer) feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) Oct 9, 2026
The test placed Block Forecast after Session Usage and matched the line
against "Session: 42.0%", so it pinned that widget's label rather than
the forecast's place on the line. Show the usage widget's raw value
instead; the test checks the same thing whatever the label is.
The docs said the forecast widgets sit right after Session Usage, which
another change renames Block Usage. Describe it as the 5-hour usage
widget instead, so the text reads right with either name; two test
titles follow.
Block Limit Timer printed `Limit: 1hr 13m`, which reads as the size of
the limit rather than the time until it's reached. It now prints
`Limit in: 1hr 13m` (`Limit in: 1h13m` short). Raw value mode is
unchanged.
The 5-hour usage history is saved by writing a temp file named after the
process id and renaming it over session-usage-history.json. When the
rename failed, for example with EPERM while a Windows virus scanner or
sync client held the new file, the error was ignored and the temp file
stayed. Every status line render is a new process with a new temp name,
so ~/.cache/ccstatusline gained another session-usage-history.json.<pid>.tmp
about once a minute, and nothing ever removed them.

A failed write now unlinks its temp file, ignoring a failed unlink. The
handle that broke the rename can refuse that unlink too, and earlier
builds have already left orphans behind, so each write also removes the
history's other <pid>.tmp files that are a minute or more old. The age
keeps it off a temp file another render is still writing. While a handle
stays held, only the last minute's temp files remain, and those from
earlier builds go on the next write.

Each render keeps its own temp name, so two renders writing at once
never write into the same file.
Sessions of one Claude config directory share the 5-hour history. An
idle session's payload keeps the percent from its own last response, so
while another session took the account from 30% to 50%, the idle one
still said 30%. The history already ignores that lower percent, but the
forecast still put it at now, after the 50% reading. The rise over the
last 30 minutes came out negative or too small, so in the idle session
Block Forecast and Block Limit Timer showed nothing, or a projection and
limit time well behind the active session's for the same account.

Usage never falls within a block, so the forecast now goes from the
higher of the session's percent and the latest reading already seen. It
hands that percent on, and Block Forecast compares its projection with
it rather than with the session's own when deciding whether the
projection only repeats the current value, so both sessions hide the
same near-flat projection.
…istory

The forecast history is kept per Claude config directory, and a reading
joined any window of it whose reset time was within an hour. After
/login to another account in the same config directory, that account's
block resets at a different time, and when that was within the hour of
the first account's reset, its readings landed in the first account's
window. Being lower than that account's last reading, they were all
dropped as a stale session's, and Block Forecast and Block Limit Timer
showed nothing, then too little once the new account passed the old
one's percent, until the old block reset.

One block's reset only differs between the usage API and the status
payload by fractions of a second, so a reading now joins a window only
when its reset is within a minute. Another login's block gets a window
of its own, unless both blocks reset at the same moment. The 5-hour
resets the usage API has sent so far fall on a whole 10-minute mark; if
blocks are rounded that way, two unrelated ones can share a reset
exactly, and no tolerance tells those apart.
…e forecast

Claude Code's refreshInterval, 10 seconds on a default install,
re-renders an idle session with the 5-hour percent from its last
response. An unchanged percent moved its reading's last-seen time to now
once a minute, so a session idle from 12:50 to 14:30 recorded "20% until
14:29". When usage on claude.ai or another machine meanwhile took the
account to 60%, the next response's reading made the whole climb look
like it happened in the last 30 minutes, instead of being spread over
the gap as a stretch without renders is: Block Limit Timer said "Limit
in: 30m" rather than 1hr 40m, and at 40% Block Forecast said →100.0%
with a limit time instead of →70.0%.

A percent from the payload now moves its reading's last-seen time no
later than the session's last API response: the timestamp of the
transcript's last assistant message, leaving out the ones Claude Code
writes itself, such as an API error's. The transcript's write time
wouldn't do: a prompt is written before its response arrives, and so is
a local command's output, while the renders they trigger still carry the
old percent. When the transcript holds no response, as a new one after
/clear doesn't until its first, the percent is from before it began, and
the reading's last sighting stays where it was. An idle stretch
therefore stays a gap. A percent from the usage API, used when the
payload has none, is current and still moves the reading to now. The
transcript is only read once a reading's last sighting is a minute old.
…rows

Changing a widget's type in the line editor keeps its metadata. Coming
from Block Usage or a reset timer in a bar mode, Block Limit Timer's row
read "(long bar, inverted)" while it rendered "Limit in: 1hr 13m", and
Block Forecast's "(long bar, remaining)" while it rendered "→16.8%".
Neither widget draws a bar or offers (p) to clear one, and the bar mode
also hid "compact" on Block Limit Timer, which still applied it.

Both rows now go through a shared helper for text-only usage widgets
that leaves out the bar mode, and inverting its fill unless the widget
shows used or remaining, before naming the modifiers.
@sirmalloc

Copy link
Copy Markdown
Owner

@eric-engberg Couple things to address on this one if you don't mind. I'm not sure the best way on the history separation by account identity. I think there might be some other open PRs to address that so it might be worth looking in to.

  • [P2] Preserve observation times for cached API usage — ccstatusline/src/utils/session-usage-history.ts:108-108
    When the payload lacks a five-hour percentage, this treats every API-derived value as freshly observed. However, fetchUsageData returns cached values, including arbitrarily stale values during failures or rate limiting. Repeated renders therefore extend an old reading's last-seen time, flattening the forecast and exaggerating the pace when fetching recovers. Propagate the actual observation timestamp from the usage fetch/cache rather than treating cached readings as current.

  • [P2] Separate history by account identity as well as reset time — ccstatusline/src/utils/session-usage-history.ts:244-245
    If /login switches accounts within the same config directory and their reset timestamps fall within one minute, this reuses the previous account's readings. Lower percentages from the new account are then discarded as stale, and getCurrentPercent substitutes the previous account's maximum. For example, switching from an account at 100% to one at 10% suppresses both forecasts until reset. Include account identity in history matching or invalidate history when credentials change.

  • [P2] Serialize the history read-modify-write transaction — ccstatusline/src/utils/session-usage-history.ts:278-280
    When multiple Claude sessions render concurrently, both processes can read the same history snapshot before either writes. The last rename then discards the other process's new readings, potentially deleting an entire newly added profile. Unique temporary filenames and atomic replacement prevent torn JSON but not lost updates, so forecasts can lose observations or regress to stale usage. Protect the complete read-modify-write operation with a cross-process lock or equivalent merge-safe storage.

When the payload carries no 5-hour percent, the forecast history took
the usage API's as seen at the render. fetchUsageData serves its cache,
though: up to three minutes old normally, and during a rate limit or an
API failure, whatever the cache holds, which can be hours old. Each
render then moved that old reading's last sighting to now. The history
showed the percent unchanged until fetching recovered, which flattened
the forecast meanwhile, and the climb since then looked like it happened
in the last few minutes, which exaggerated the pace afterwards.

fetchUsageData now keeps when the API sent the values it returns: now
for a fresh answer, the cache file's write time for a cached one.
getUsageFetchedAtMs exposes it, and the history dates an API percent by
it, as it already dates a payload percent by the transcript's last
response. The stretch since then stays a gap that a later change is
spread across.

A new percent is now also dated by when it was seen rather than when it
is first written, from either source: a profile can find another
profile's fetch in the shared usage cache, say. It is never dated before
the last reading's last sighting.
The renders of every session and profile share
session-usage-history.json. A render read the whole file, added its
reading and renamed a new file over it, so when two renders overlapped,
the later rename put back what it had read: the other render's new
reading was lost, or a whole profile it had just added. Unique temp file
names only kept the JSON from tearing.

A render with something to write now creates
session-usage-history.json.lock exclusively, reads the history again
under it, writes, and removes the lock. A render that finds the lock
held doesn't wait: it forecasts from what it read and leaves the write
to its next render, which records the same percent with the same time,
since a reading is dated by when its percent was seen. A lock more than
10 seconds old, or dated that far ahead, was left by a render that died
holding it, and is taken over. Renders with nothing to write don't take
the lock.
The forecast history was kept per Claude config directory, and a reading
joined the window whose reset was within a minute of its own. After
/login to another account in the same directory, that account's block
can reset at the same moment as the last one's, since the usage API's
resets fall on whole 10-minute marks. Its readings then joined the
first account's window, where a lower percent counted as a stale
session's and was dropped, and the forecast went from the first
account's percent: switching from an account at 100% to one at 10% hid
Block Forecast and Block Limit Timer until the reset.

The history now goes by config directory and login, the login being the
usage cache's fingerprint of its refresh token (sirmalloc#536). That stays the
same when Claude Code reissues the access token every few hours, and
changes with /login. getUsageLoginFingerprint reads it through the usage
widgets' credential lookup, including its 30-second backoff after
finding no login. Without one (a token from the environment, say), the
history goes by the config directory alone, as before.

On macOS that is one keychain read per render that has a forecast widget
and a 5-hour percent; a render whose percent comes from the payload
didn't read the keychain before.
…o it

The docs said to add Block Forecast and Block Limit Timer as separate
widgets, since a merge carried on across a widget that rendered nothing:
a hidden forecast joined Block Usage to whatever came next. Since sirmalloc#698,
a merge ends at a hidden widget that isn't merged onward, so merging
Block Usage into the forecast (with the forecast's label set to ` →`,
say) reads as one value while the forecast shows and leaves the line as
it was while it's hidden.
@eric-engberg

Copy link
Copy Markdown
Contributor Author

Pushed three commits for these, one per point:

  • Cached API usage (bded3b6): fetchUsageData now keeps when the API sent the values it returns: now for a fresh answer, the cache file's write time for a cached one, including the stale cache served during a rate limit or failure (getUsageFetchedAtMs). The history dates an API percent by that, as it already did a payload percent by the transcript's last response. Renders during a rate limit no longer move the old reading forward, and once fetching recovers, the climb is spread over the gap. A new reading is also dated by when its percent was seen rather than when it's written.
  • Account identity (d02e570): main already fingerprints the login for the usage cache by its refresh token (fix(usage): fingerprint the usage cache by refresh token so a routine token refresh is not read as an account switch #536). That survives access-token refreshes and changes with /login, and fix(account-email): show the account the live credentials belong to #724 keys its email cache the same way. The history is now keyed by config directory plus that fingerprint (getUsageLoginFingerprint, through the same credential lookup and no-login backoff). In your example, an account at 10% after one at 100% gets its own window even when both reset at the same moment. Without an OAuth login it falls back to the config directory alone. The cost: on macOS, a render whose percent comes from the payload now does one keychain read when a forecast widget is on the line.
  • Concurrent writes (87b3563): a render with something to write creates session-usage-history.json.lock exclusively, re-reads the history under it, writes, and removes it. A render that finds the lock held doesn't wait. It forecasts from what it read and leaves the write to its next render, which records the same reading with the same time, since readings are now dated by when their percent was seen. A lock more than 10 seconds old is taken as left by a render that died, and taken over.

I also merged today's main, and with #698 in, the docs no longer advise against merging Block Usage into the forecast. Before/after renders for the first two points are at the end of the Demo section.

@sirmalloc
sirmalloc merged commit 577eb5b into sirmalloc:main Oct 29, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Oct 29, 2026
Upstream: Extra Usage Limit widget (sirmalloc#756), live widget preview while picking (sirmalloc#755), Fast Mode widget (sirmalloc#754), transcript block fallback fixes (sirmalloc#752), editor keys on a global install (sirmalloc#751), git cache TTL (sirmalloc#750), npx cached copy skip (sirmalloc#749), Block Usage hide at 100% (sirmalloc#747), Block Reset Timer hide until 100% (sirmalloc#697), fish-style branch abbreviation (sirmalloc#614), jj detached HEAD (sirmalloc#705), hooks save warning (sirmalloc#703), dev reload (sirmalloc#700), merge ends at a hidden widget (sirmalloc#698), Block Forecast and Block Limit Timer widgets (sirmalloc#699), contributor guide (sirmalloc#706), POSIX path case fix (sirmalloc#707), whitespace-distinct conflict filenames (sirmalloc#709), Quick Start reorder (sirmalloc#715), bunx launch guidance (sirmalloc#714), jj diff summary counts (sirmalloc#711), powerline manual separators (sirmalloc#713), keychain credential by account (sirmalloc#532), Keychain probe timeout (sirmalloc#760), usage cache scoped per CLAUDE_CONFIG_DIR (sirmalloc#266), test isolation (sirmalloc#717, sirmalloc#753), dev-dependency bumps (sirmalloc#745)

Conflicts resolved per the standard-resolution table; fork deletions re-applied.

Hand edits outside conflicts:
- src/utils/global-command-resolution.ts: fork-rename (ccstatusline@ -> ccstatusline-usage@ in upstream's new bunx pattern)
- src/utils/__tests__/global-package-manager.test.ts: fork-rename (package dir ccstatusline-usage in upstream's new tests)
- src/utils/usage-fetch.ts: compat fix - cacheUsageData gained a fetchedAtMs arg (upstream sirmalloc#266); adapted the fork-only fetchFreshUsageData call site
- src/utils/__tests__/usage-fetch.test.ts: compat fixes - cache/lock now hashed per CLAUDE_CONFIG_DIR (usageFilePath helper) in four fork tests; upstream fableUsage renamed to fork's weeklyFableUsage; seedUsageCache takes TokenHome
- src/utils/__tests__/usage-prefetch.test.ts: compat fix - mockFetchUsageData renamed to upstream's mockFetchUsage in cleanly-merged tests
- README.md: took upstream's Quick Start move (sirmalloc#715) and dropped the fork's old copy; fork-rename of its ccstatusline@ specifiers; links to fork-deleted docs (docs/USAGE.md, docs/WINDOWS.md) replaced with the fork's inline text
- CONTRIBUTING.md: links to the fork-deleted docs/DEVELOPMENT.md pointed at the README Development section
- docs/test-retirements.md: ledger entries for the 12 upstream test renames (sirmalloc#697, sirmalloc#698, sirmalloc#700, sirmalloc#705, sirmalloc#713, sirmalloc#760)
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 30, 2026
Merged against #72's final head (355af2e), which upstream squash-merged
as sirmalloc#699, so only main's changes since then come in.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 30, 2026
…colors

Brings in upstream main through #46. #72 is upstream as sirmalloc#699, so the
files this branch had from it take main's versions, and only Block
Forecast's value colors stay on top: its widget, its tests and the docs
lines for (v). Main's BlockForecast.ts matches the #72 code this branch
built on, so the value colors apply to it unchanged.

# Conflicts:
#	docs/DEVELOPMENT.md
#	docs/USAGE.md
#	src/types/RenderContext.ts
#	src/utils/__tests__/usage-fetch.test.ts
#	src/utils/usage-fetch.ts
#	src/utils/usage.ts
#	src/widgets/BlockForecast.ts
#	src/widgets/__tests__/BlockForecast.test.ts
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 30, 2026
Merged against #72's final head (355af2e), which upstream squash-merged
as sirmalloc#699, so only main's changes since then come in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants