Repository navigation
feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) - #699
Conversation
# Conflicts: # docs/USAGE.md
…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.
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.
# Conflicts: # docs/USAGE.md
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.
|
@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.
|
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.
|
Pushed three commits for these, one per point:
I also merged today's |
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)
Merged against #72's final head (355af2e), which upstream squash-merged as sirmalloc#699, so only main's changes since then come in.
…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
Merged against #72's final head (355af2e), which upstream squash-merged as sirmalloc#699, so only main's changes since then come in.
What
Two new Usage widgets forecast the 5-hour block from the recent pace:
→83.2%.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:
5h: 42.0% →83.2%5h: 61.0% →100.0% Limit in: 1hr 13m5h: 100.0%5h: 42.0%Block Forecast has
u(show remaining, like Block Usage:→16.8%), and Block Limit Timer hass(short time:1h13m). Both support raw value mode and their own colors; Block Limit Timer defaults to red. Pair them with Block Reset Timer'sunder-limithide 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
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.~/.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.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.sessionUsage/sessionResetAtBlock Usage uses (the payload'srate_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.→100.0%and Block Limit TimerLimit 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
Limit in: 30minstead 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.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./loginto 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.(long bar, inverted)while it renderedLimit 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 hidcompacton 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.session-usage-history.json.<pid>.tmpand renaming it oversession-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 othersession-usage-history.json.<digits>.tmpfiles 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.fetchUsageDataserves 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.fetchUsageDatanow keeps when the API sent the values it returns: now for a fresh answer, the cache file's write time for a cached one.getUsageFetchedAtMsexposes 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.session-usage-history.json.lockexclusively, 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./login.getUsageLoginFingerprintreads 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.mainas it is.Demo
Fake data: a seeded reading history and payloads with the reset 1hr 43m away, in four situations.
Powerline
Plain
Adding both after Block Usage with the picker's search:
Powerline
Plain
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:
→, so the forecast reads as part of the percent:5h: 42.0% →83.2%;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
Plain
The line editor holding that line:
Powerline
Plain
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:
Plain, before and after:
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:
Plain, before and after:
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: 45mand→100.0% | Limit in: 19m.Powerline, before and after:
Plain, before and after:
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:
Plain, before and after:
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:
Plain, before and after:
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:
Plain, before and after:
Testing
bun test: 3129 pass, 0 fail.bun run lintpasses. The new test files never write toHOME(run withHOMEpointed at an empty directory).mainonly where the new widgets show. The TUI opens and exits cleanly under both.main, which renames Session Usage to Block Usage (fix(usage): call Session Usage Block Usage, ending its "Session:" label clash #708):bun test3242 pass, 0 fail,bun run lintpasses, and the built CLI's plain and Powerline renders matchmainunder Bun 1.4.2 and Node 26.10.0. The examples and media above are re-recorded with Block Usage's5h:label.Limit in: 1hr 13m, which reads as the time until the limit rather than its size):bun test3129 pass, 0 fail;bun run lintpasses; under Node (Vitest) the two changed test files pass, 13 of 13; the built CLI matchesmainwith the baseline configs under Bun and Node. The media is re-recorded for the new label.Update:
→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.dist/ccstatusline.jsunder 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.Limit in: 30mtoLimit in: 1hr 40m. At 40% it went from→100.0% | Limit in: 1hr 30mto→70.0%.→70.0%and nothing to→100.0% | Limit in: 1hr 40m, the same as the active session./loginto 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.(long bar, inverted)and(long bar, remaining)to(compact)and(remaining).bun test: 3263 pass, 0 fail, 9311expect()calls across 184 files.bun run lint(tsc --noEmitplus ESLint with--max-warnings=0): clean.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.src/utils/__tests__/session-usage-history.test.ts:HOMEandCLAUDE_CONFIG_DIR, using the dist built with the package.json build flags:session-usage-history.json.<n>.tmpfiles.daily.json.333.tmpand a plainsession-usage-history.json.tmp.chflags uchgon the history file, standing in for a held Windows handle, left no new temp files.getUsageFetchedAtMs.bun test: 3278 pass, 0 fail across 184 files.bun run lint: clean.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 test3359 pass, 0 fail across 186 files;bun run lintclean.fetchUsageDataprobe 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 (itsnode:child_processmock has nospawn).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.