From a3a80e72554509a7a697e8a93deb79e6996e75cd Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 15:50:50 -0500 Subject: [PATCH 1/3] fix(usage): refetch a cached usage answer from before a 5-hour or weekly reset Values the statusline payload doesn't carry, such as Weekly Fable Usage and the extra usage figures, come only from the usage API, whose answer is cached for 180 seconds and shared by every session. When the 5-hour or weekly window reset, Claude Code's payload already showed the new window while the cached answer still described the old one. Fable Weekly Usage then kept the previous week's percentage until the cache expired, up to three minutes after the reset. prefetchUsageDataIfNeeded now passes the payload's five_hour and seven_day reset ends to fetchUsageData. A memory or file cache that ends either window more than 60 seconds before the payload does was written before that reset, so it no longer counts as fresh and is fetched again. The two sources give the same instant to within a second, while a reset moves the end by 5 hours or 7 days. Only a later payload end counts. A session idle since before the reset keeps showing the window of its last request, and must not refetch an answer another session has already brought up to date. Percentages aren't compared for the same reason: an idle session's stay where they were. The refetch keeps the existing guards. Under an active usage lock (a 429 backoff, another render's fetch in flight, a parse error) or the no-credentials backoff, the pre-reset answer is served as the stale fallback, as before. A response that still ends the window before the payload does is cached but keeps the 30-second in-flight lock, so an API that lags the payload is asked at most every 30 seconds rather than on every render. Once a response matches the payload, later renders serve it from the cache. A reset of a window the payload doesn't carry, such as a Fable window ending at a different time from the weekly one, can't be seen this way and still waits for the cache to expire. --- docs/DEVELOPMENT.md | 2 + docs/USAGE.md | 2 + src/utils/__tests__/usage-fetch.test.ts | 262 ++++++++++++++++++++- src/utils/__tests__/usage-prefetch.test.ts | 62 ++++- src/utils/usage-fetch.ts | 54 ++++- src/utils/usage-prefetch.ts | 23 +- src/utils/usage.ts | 5 +- 7 files changed, 392 insertions(+), 18 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5579cf476..d8f4e4582 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -80,6 +80,8 @@ Usage-lock deadlines more than 24 hours ahead are treated as poisoned and ignore After a complete successful usage fetch, the process clears the lock only if its contents still match the in-flight record it wrote, preserving a concurrent render's newer rate-limit backoff. +`prefetchUsageDataIfNeeded` passes the payload's `rate_limits.five_hour` and `seven_day` reset ends to `fetchUsageData` as `payloadResetAt`. A memory or file cache that ends either window more than 60 seconds before the payload does was written before that window reset, so it no longer counts as fresh. Only a later payload end counts, because an idle session's payload keeps the window of its last request, and a window without an end on both sides isn't compared. The refetch goes through the usual guards: under an active usage lock (rate-limit, in-flight or parse-error) or the no-credentials backoff, the pre-reset answer is served as the stale fallback. A response that still ends the window before the payload does is cached but keeps the 30-second in-flight lock, so an API that lags the payload is asked at most every 30 seconds rather than on every render. Once a response matches the payload, later renders serve it from the cache. + ## Widget Data Sources - **Transcript-backed widgets** stream the active JSONL transcript once per render through `getTranscriptAnalysis()`, collecting token, duration, speed, compaction, thinking-effort, and session-name data in one pass without materializing the whole file. Referenced subagent transcripts are streamed separately only when speed metrics include subagents. diff --git a/docs/USAGE.md b/docs/USAGE.md index a60352bee..1fbd27483 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -191,6 +191,8 @@ The usage cache uses a fingerprint of the refresh token when available, falling Each macOS Keychain command has a five-second timeout. When no OAuth credentials are found, the lookup backs off for 30 seconds for that profile, so a newly signed-in account may take up to 30 seconds to appear. Usage cache files dated more than 180 seconds into the future are treated as stale. +Usage API answers are cached for 180 seconds and shared by every session. Values that Claude Code's status line data doesn't include, such as Weekly Fable Usage and the extra usage figures, come only from the usage API, through that cache. When the status line data's 5-hour or weekly reset time is more than a minute later than the cached answer's, that window has reset since the answer was cached, so the render fetches a new one instead of showing the previous window's numbers for the rest of the 180 seconds. While a fetch is already running, or fetches are paused after a failed or rate-limited one, the previous answer stays on screen until the next fetch. If the API still answers with the previous window, ccstatusline asks again at most every 30 seconds until it doesn't. A reset of a window the status line data doesn't include, such as a Fable window ending at a different time from the weekly one, still shows only once the cache expires. + ### Proxy Settings Usage requests to `api.anthropic.com` and Claude Status requests to `status.claude.com` honor uppercase `HTTPS_PROXY`. Both `NO_PROXY` and `no_proxy` supply exclusions; matching either variable sends the request directly. diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 5cb7b3cea..c5424ea19 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -54,6 +54,7 @@ interface ProbeOptions { noProxy?: string; mode?: 'error' | 'status' | 'success' | 'unexpected'; nowMs: number; + payloadResetAt?: Record; pathDir?: string; requiredFields?: string[]; responseBody?: string; @@ -161,10 +162,11 @@ const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock') const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json'); const nowMs = Number(process.env.TEST_NOW_MS || Date.now()); const requiredFields = JSON.parse(process.env.TEST_REQUIRED_FIELDS_JSON || '[]'); +const payloadResetAt = process.env.TEST_PAYLOAD_RESET_AT_JSON ? JSON.parse(process.env.TEST_PAYLOAD_RESET_AT_JSON) : undefined; Date.now = () => nowMs; -const first = await fetchUsageData({ requiredFields }); -const second = await fetchUsageData({ requiredFields }); +const first = await fetchUsageData({ requiredFields, payloadResetAt }); +const second = await fetchUsageData({ requiredFields, payloadResetAt }); process.stdout.write(JSON.stringify({ first, second, @@ -246,6 +248,10 @@ process.stdout.write(JSON.stringify({ env.TEST_LOCK_DURING_REQUEST = options.lockWrittenDuringRequest; } + if (options.payloadResetAt !== undefined) { + env.TEST_PAYLOAD_RESET_AT_JSON = JSON.stringify(options.payloadResetAt); + } + if (options.lowercaseHttpsProxy !== undefined) { env.https_proxy = options.lowercaseHttpsProxy; } @@ -442,6 +448,34 @@ describe('fetchUsageData error handling', () => { type: 'rate_limit_error' } }); + // The usage API's answer from just before a weekly reset (Fable at 88% in + // the week ending 2030-01-07), and its answers once the new week started. + const beforeWeeklyResetUsage = { + sessionUsage: 42, + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyUsage: 97, + weeklyResetAt: '2030-01-07T00:00:00.000Z', + fableUsage: 88, + fableResetAt: '2030-01-07T00:00:00.000Z' + }; + const beforeWeeklyResetResponseBody = JSON.stringify({ + five_hour: { utilization: 42, resets_at: '2030-01-07T03:00:00.000Z' }, + seven_day: { utilization: 97, resets_at: '2030-01-07T00:00:00.000Z' }, + limits: [ + { kind: 'weekly_scoped', percent: 88, resets_at: '2030-01-07T00:00:00.000Z', scope: { model: { display_name: 'Fable' } } } + ] + }); + const afterWeeklyResetResponseBody = JSON.stringify({ + five_hour: { utilization: 42, resets_at: '2030-01-07T03:00:00.000Z' }, + seven_day: { utilization: 1, resets_at: '2030-01-14T00:00:00.000Z' }, + limits: [ + { kind: 'weekly_scoped', percent: 3, resets_at: '2030-01-14T00:00:00.000Z', scope: { model: { display_name: 'Fable' } } } + ] + }); + const afterWeeklyResetPayload = { + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyResetAt: '2030-01-14T00:00:00.000Z' + }; // The request carries the account's bearer token, so a host the user kept // off the proxy must stay off it @@ -1519,6 +1553,230 @@ describe('fetchUsageData error handling', () => { } }); + it('refetches a fresh cache from before a weekly reset the statusline payload shows, once', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('weekly-reset-refetch'); + const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16); + const { cacheFile, mtimeMs } = seedUsageCache(home.home, { ...beforeWeeklyResetUsage, tokenHash: matchingHash }); + + // Ten seconds after the cache was written the week reset, and + // Claude Code's payload already ends the week seven days later. + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: mtimeMs + 10000, + pathDir: home.bin, + payloadResetAt: afterWeeklyResetPayload, + requiredFields: ['fableUsage'], + responseBody: afterWeeklyResetResponseBody + }); + + expect(result.requestCount).toBe(1); + expect(result.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyUsage: 1, + weeklyResetAt: '2030-01-14T00:00:00.000Z', + fableUsage: 3, + fableResetAt: '2030-01-14T00:00:00.000Z' + }); + expect(result.second).toEqual(result.first); + expect(result.lockExists).toBe(false); + + // The next render, with the same payload: the new answer stands. + const nextRender = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: fs.statSync(cacheFile).mtimeMs + 10000, + pathDir: home.bin, + payloadResetAt: afterWeeklyResetPayload, + requiredFields: ['fableUsage'] + }); + + expect(nextRender.requestCount).toBe(0); + expect(nextRender.first).toEqual(result.first); + } finally { + harness.cleanup(); + } + }); + + it('refetches a fresh cache from before a 5-hour reset the statusline payload shows', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('session-reset-refetch'); + const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { + sessionUsage: 100, + sessionResetAt: '2030-01-03T10:00:00.000Z', + weeklyUsage: 60, + weeklyResetAt: '2030-01-07T00:00:00.000Z', + tokenHash: matchingHash + }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: mtimeMs + 10000, + pathDir: home.bin, + payloadResetAt: { + sessionResetAt: '2030-01-03T15:00:00.000Z', + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }, + requiredFields: ['sessionUsage'], + responseBody: JSON.stringify({ + five_hour: { utilization: 4, resets_at: '2030-01-03T15:00:00.000Z' }, + seven_day: { utilization: 61, resets_at: '2030-01-07T00:00:00.000Z' } + }) + }); + + expect(result.requestCount).toBe(1); + expect(result.first).toEqual({ + sessionUsage: 4, + sessionResetAt: '2030-01-03T15:00:00.000Z', + weeklyUsage: 61, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + } finally { + harness.cleanup(); + } + }); + + it.each([ + ['shows the same reset ends', beforeWeeklyResetUsage, { + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }], + ['ends the week at most a minute later', beforeWeeklyResetUsage, { + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyResetAt: '2030-01-07T00:01:00.000Z' + }], + // A session idle since the week before still shows that week's end + ['ends the week earlier', beforeWeeklyResetUsage, { + sessionResetAt: '2030-01-07T03:00:00.000Z', + weeklyResetAt: '2029-12-31T00:00:00.000Z' + }], + // As the API answers for a window nothing has been used in yet + ['ends a week the cached answer has no end for', { sessionUsage: 0, weeklyUsage: 0, fableUsage: 0 }, afterWeeklyResetPayload] + ])('serves a fresh cache when the statusline payload %s', (_label, cachedUsage, payloadResetAt) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('payload-reset-no-refetch'); + const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { ...cachedUsage, tokenHash: matchingHash }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: mtimeMs + 10000, + pathDir: home.bin, + payloadResetAt, + requiredFields: ['fableUsage'] + }); + + expect(result.requestCount).toBe(0); + expect(result.first).toEqual(cachedUsage); + expect(result.second).toEqual(result.first); + } finally { + harness.cleanup(); + } + }); + + it.each([ + ['during a rate-limit backoff', (home: TokenHome, probeNowMs: number) => { + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(probeNowMs / 1000) + 300, + error: 'rate-limited' + })); + }], + ['while another render\'s fetch is in flight', (home: TokenHome, probeNowMs: number) => { + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(probeNowMs / 1000) + 30, + error: 'timeout' + })); + }], + ['without a login', (home: TokenHome) => { + fs.rmSync(path.join(home.claudeConfig, '.credentials.json')); + }] + ])('does not refetch for a reset the statusline payload shows %s', (_label, block) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('payload-reset-blocked'); + const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { ...beforeWeeklyResetUsage, tokenHash: matchingHash }); + const probeNowMs = mtimeMs + 10000; + block(home, probeNowMs); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: probeNowMs, + pathDir: home.bin, + payloadResetAt: afterWeeklyResetPayload, + requiredFields: ['fableUsage'] + }); + + // The answer from before the reset is still the best there is + expect(result.requestCount).toBe(0); + expect(result.first).toEqual(beforeWeeklyResetUsage); + expect(result.second).toEqual(result.first); + } finally { + harness.cleanup(); + } + }); + + it('keeps the in-flight lock while a refetch still answers with the week before the payload reset', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('payload-reset-api-behind'); + const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { ...beforeWeeklyResetUsage, tokenHash: matchingHash }); + const probeNowMs = mtimeMs + 10000; + const renderAt = (renderNowMs: number, mode: ProbeOptions['mode'], responseBody?: string) => harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode, + nowMs: renderNowMs, + pathDir: home.bin, + payloadResetAt: afterWeeklyResetPayload, + requiredFields: ['fableUsage'], + responseBody + }); + + const result = renderAt(probeNowMs, 'success', beforeWeeklyResetResponseBody); + + expect(result.requestCount).toBe(1); + expect(result.first).toEqual(beforeWeeklyResetUsage); + expect(result.second).toEqual(result.first); + expect(parseLockContents(result.lockContents)).toEqual({ + blockedUntil: Math.floor(probeNowMs / 1000) + 30, + error: 'timeout' + }); + + // Renders inside the lock serve that answer without asking again + const lockedRender = renderAt(probeNowMs + 10000, 'unexpected'); + expect(lockedRender.requestCount).toBe(0); + expect(lockedRender.first).toEqual(beforeWeeklyResetUsage); + + const retried = renderAt(probeNowMs + 31000, 'success', afterWeeklyResetResponseBody); + expect(retried.requestCount).toBe(1); + expect(retried.first.fableUsage).toBe(3); + expect(retried.lockExists).toBe(false); + } finally { + harness.cleanup(); + } + }); + it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => { const harness = createProbeHarness(); diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts index bf3783b5e..a4a9b5766 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -240,7 +240,45 @@ describe('usage prefetch', () => { expect(usageData?.fableUsage).toBe(5); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ + requiredFields: ['fableUsage'], + payloadResetAt: { + sessionResetAt: epochToIso(1774020000), + weeklyResetAt: epochToIso(1774540000) + } + }]); + }); + + it('hands the fetch the payload\'s reset ends so a cached answer from before a reset is replaced', async () => { + mockFetchUsageData.mockResolvedValue({ + weeklyUsage: 1, + weeklyResetAt: epochToIso(1774540000), + fableUsage: 3, + fableResetAt: epochToIso(1774540000) + }); + + const lines = makeLines( + [{ id: '1', type: 'fable-weekly-usage' }, { id: '2', type: 'weekly-usage' }] + ); + + // Just after a weekly reset: the payload already ends the week seven + // days later, while Fable usage comes only from the usage API. + const usageData = await prefetchUsageDataIfNeeded(lines, { + rate_limits: { + five_hour: { used_percentage: 12, resets_at: 1774020000 }, + seven_day: { used_percentage: 1, resets_at: 1774540000 } + } + }); + + expect(mockFetchUsageData.mock.calls).toEqual([[{ + requiredFields: ['fableUsage'], + payloadResetAt: { + sessionResetAt: epochToIso(1774020000), + weeklyResetAt: epochToIso(1774540000) + } + }]]); + expect(usageData?.fableUsage).toBe(3); + expect(usageData?.weeklyUsage).toBe(1); }); it.each([ @@ -323,7 +361,7 @@ describe('usage prefetch', () => { weeklyResetAt: epochToIso(1774540000) }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionResetAt'] }] + [{ requiredFields: ['sessionResetAt'], payloadResetAt: { weeklyResetAt: epochToIso(1774540000) } }] ]); }); @@ -402,7 +440,10 @@ describe('usage prefetch', () => { weeklySonnetUsage: 8 }); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ + requiredFields: ['weeklySonnetUsage'], + payloadResetAt: { weeklyResetAt: epochToIso(1774540000) } + }]); }); it('uses aggregate weekly reset as the cursor fallback without fetching for fable reset data', async () => { @@ -419,7 +460,10 @@ describe('usage prefetch', () => { fableUsage: 8 }); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ + requiredFields: ['fableUsage'], + payloadResetAt: { weeklyResetAt: epochToIso(1774540000) } + }]); }); it('fetches extra usage fields while preserving statusline usage data', async () => { @@ -539,9 +583,13 @@ describe('usage prefetch', () => { expect(usageData?.weeklySonnetUsage).toBe(8); expect(usageData?.weeklyOpusUsage).toBe(2); - expect(mockFetchUsageData.mock.calls).toEqual([[ - { requiredFields: ['weeklySonnetUsage', 'weeklyOpusUsage'] } - ]]); + expect(mockFetchUsageData.mock.calls).toEqual([[{ + requiredFields: ['weeklySonnetUsage', 'weeklyOpusUsage'], + payloadResetAt: { + sessionResetAt: epochToIso(1774020000), + weeklyResetAt: epochToIso(1774540000) + } + }]]); }); it('does not let null per-model stdin buckets overwrite scoped API usage fetched for another widget', async () => { diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 5aae11a0b..2a3ea267d 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -43,7 +43,23 @@ const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; // unlock prompt must not hold the status line until someone answers it. const MACOS_SECURITY_TIMEOUT_MS = 5000; -export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] } +// How much later than a cached usage API answer's reset end the statusline +// payload's end for the same window may be before that answer counts as one +// from before a reset. Both report the same instant, the payload in whole +// seconds and the API in fractions of one, while a reset moves the end by the +// window's whole length: 5 hours or 7 days. +const PAYLOAD_RESET_TOLERANCE = 60; // seconds + +// The 5-hour and weekly reset ends Claude Code's statusline payload shows +// (rate_limits.five_hour / seven_day resets_at), as ISO timestamps. +export type PayloadResetAt = Pick; + +const PAYLOAD_RESET_FIELDS = ['sessionResetAt', 'weeklyResetAt'] as const; + +export interface FetchUsageDataOptions { + requiredFields?: readonly UsageDataField[]; + payloadResetAt?: PayloadResetAt; +} // The access token is what the API is called with; the refresh token is kept // alongside it only to fingerprint the account (see getUsageCacheIdentity). @@ -415,6 +431,30 @@ function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageD return requiredFields.every(field => hasRequiredUsageField(data, field)); } +// Whether the statusline payload shows a 5-hour or weekly window that began +// after this usage API answer was given: the payload ends that window more +// than PAYLOAD_RESET_TOLERANCE later than the answer does. Only a later end +// counts: a session idle since before a reset keeps showing the window of its +// last request, while another session may already have fetched the new one. +// A window that either side gives no end for isn't compared. +function predatesPayloadReset(data: UsageData, payloadResetAt: PayloadResetAt | undefined): boolean { + return PAYLOAD_RESET_FIELDS.some((field) => { + const payloadResetMs = Date.parse(payloadResetAt?.[field] ?? ''); + const answerResetMs = Date.parse(data[field] ?? ''); + if (Number.isNaN(payloadResetMs) || Number.isNaN(answerResetMs)) { + return false; + } + return payloadResetMs - answerResetMs > PAYLOAD_RESET_TOLERANCE * 1000; + }); +} + +// Whether a usage API answer can stand in for a new request: it holds every +// field the caller needs, and the statusline payload shows no reset since. +function answersUsageRequest(data: UsageData, options: FetchUsageDataOptions): boolean { + return hasRequiredUsageFields(data, options.requiredFields) + && !predatesPayloadReset(data, options.payloadResetAt); +} + function getStaleUsageOrError( error: UsageError, now: number, @@ -868,7 +908,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi // Check memory cache (fast path) if (cachedUsageData) { const cacheAge = now - usageCacheTime; - if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE && hasRequiredUsageFields(cachedUsageData, requiredFields)) { + if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE && answersUsageRequest(cachedUsageData, options)) { return cachedUsageData; } if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) { @@ -897,7 +937,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi const fileData = parseCachedUsageData(rawCache); if (fileData && !fileData.error && tokenHashMatches(readCachedTokenHash(rawCache), cacheIdentity) - && hasRequiredUsageFields(fileData, requiredFields)) { + && answersUsageRequest(fileData, options)) { return cacheUsageData(fileData, now); } } @@ -956,9 +996,11 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi } // Clear the in-flight lock written above only once this response satisfies - // the caller's requested fields. Incomplete 200 responses are cached but - // still need the short throttle so later renders do not refetch every time. - if (hasRequiredUsageFields(usageData, requiredFields)) { + // the caller's requested fields and shows the windows the statusline + // payload does. Incomplete 200 responses, and ones still from before a + // reset the payload shows, are cached but still need the short throttle + // so later renders do not refetch every time. + if (answersUsageRequest(usageData, options)) { clearOwnUsageLock(inFlightLock); } diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts index f50f63739..2b1b37518 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -4,7 +4,10 @@ import type { } from '../types/StatusJSON'; import type { WidgetItem } from '../types/Widget'; -import type { UsageData } from './usage'; +import type { + PayloadResetAt, + UsageData +} from './usage'; import { fetchUsageData } from './usage'; import type { UsageDataField } from './usage-types'; import { @@ -229,6 +232,19 @@ export function extractUsageDataFromRateLimits(rateLimits: StatusJSON['rate_limi return hasAnyUsageDataField(usageData) ? usageData : null; } +// The payload's 5-hour and weekly reset ends, which tell fetchUsageData that a +// cached usage API answer is from before a reset. Undefined when it has neither. +function getPayloadResetAt(rateLimitsData: UsageData | null): PayloadResetAt | undefined { + if (rateLimitsData?.sessionResetAt === undefined && rateLimitsData?.weeklyResetAt === undefined) { + return undefined; + } + + return { + sessionResetAt: rateLimitsData.sessionResetAt, + weeklyResetAt: rateLimitsData.weeklyResetAt + }; +} + export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: StatusJSON): Promise { if (!hasUsageDependentWidgets(lines)) { return null; @@ -243,7 +259,10 @@ export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: St return rateLimitsData; } - const apiData = await fetchUsageData({ requiredFields: missingFields }); + const apiData = await fetchUsageData({ + requiredFields: missingFields, + payloadResetAt: getPayloadResetAt(rateLimitsData) + }); if (apiData.error && missingRequirements.suppressFetchError) { return rateLimitsData; } diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 0532b7819..3cfa7c476 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1,4 +1,7 @@ -export { fetchUsageData } from './usage-fetch'; +export { + fetchUsageData, + type PayloadResetAt +} from './usage-fetch'; export { formatUsageDuration, formatUsageResetAt, From ffc01ca7d0bdf47c553aaf66725ca1e1ad3baede Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 30 Oct 2026 19:01:29 -0500 Subject: [PATCH 2/3] test(usage): expect the payload's reset ends in Weekly Fable Limit Timer's prefetch calls This branch passes the statusline payload's 5-hour and weekly reset ends to fetchUsageData, so #768's two prefetch tests for Weekly Fable Limit Timer, which check the exact fetchUsageData calls, expect that option too. --- src/utils/__tests__/usage-prefetch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts index f4a640e02..3abd4a898 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -454,7 +454,7 @@ describe('usage prefetch', () => { expect(usageData).toEqual({ weeklyUsage: 72, weeklyResetAt: epochToIso(1776211200), fableUsage: 8 }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['fableUsage'] }] + [{ requiredFields: ['fableUsage'], payloadResetAt: { weeklyResetAt: epochToIso(1776211200) } }] ]); }); @@ -467,7 +467,7 @@ describe('usage prefetch', () => { expect(usageData).toEqual({ weeklyUsage: 72, weeklyResetAt: epochToIso(1776211200) }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['fableUsage'] }] + [{ requiredFields: ['fableUsage'], payloadResetAt: { weeklyResetAt: epochToIso(1776211200) } }] ]); }); From ad4b885f68c934bf3d63cc09eec26a439e84ed6f Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 6 Nov 2026 12:02:29 -0600 Subject: [PATCH 3/3] docs(usage): note that the stale fallback leaves out windows past their reset Since the stale fallback leaves out windows whose own reset time has passed, the pre-reset answer a blocked refetch falls back to no longer shows such a window. --- docs/DEVELOPMENT.md | 2 +- docs/USAGE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 166c61bea..332ea7c43 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -99,7 +99,7 @@ After a complete successful usage fetch, the process clears the lock only if its Stale-cache fallback removes each expired window's usage and reset fields while retaining other usable fields. Partial fallback data can carry an error for missing required fields without suppressing widgets whose data remains valid. `getUsageFetchedAtMs()` preserves the original fetch time (disk-cache mtime for cached answers), rather than treating a stale read as a new observation. -`prefetchUsageDataIfNeeded` passes the payload's `rate_limits.five_hour` and `seven_day` reset ends to `fetchUsageData` as `payloadResetAt`. A memory or file cache that ends either window more than 60 seconds before the payload does was written before that window reset, so it no longer counts as fresh. Only a later payload end counts, because an idle session's payload keeps the window of its last request, and a window without an end on both sides isn't compared. The refetch goes through the usual guards: under an active usage lock (rate-limit, in-flight or parse-error) or the no-credentials backoff, the pre-reset answer is served as the stale fallback. A response that still ends the window before the payload does is cached but keeps the 30-second in-flight lock, so an API that lags the payload is asked at most every 30 seconds rather than on every render. Once a response matches the payload, later renders serve it from the cache. +`prefetchUsageDataIfNeeded` passes the payload's `rate_limits.five_hour` and `seven_day` reset ends to `fetchUsageData` as `payloadResetAt`. A memory or file cache that ends either window more than 60 seconds before the payload does was written before that window reset, so it no longer counts as fresh. Only a later payload end counts, because an idle session's payload keeps the window of its last request, and a window without an end on both sides isn't compared. The refetch goes through the usual guards: under an active usage lock (rate-limit, in-flight or parse-error) or the no-credentials backoff, the pre-reset answer is served as the stale fallback, without the windows whose own reset end has passed. A response that still ends the window before the payload does is cached but keeps the 30-second in-flight lock, so an API that lags the payload is asked at most every 30 seconds rather than on every render. Once a response matches the payload, later renders serve it from the cache. ## Widget Data Sources diff --git a/docs/USAGE.md b/docs/USAGE.md index eb52fff5b..9c043eb08 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -220,7 +220,7 @@ Each macOS Keychain command has a five-second timeout. If a command times out, c Claude Account Email uses the same credentials to look up the account's email from `/api/oauth/profile`. The answer is cached per login fingerprint, so an access-token refresh does not repeat the request. A failed lookup is retried after 30 seconds, or after the `Retry-After` of a 429 response (five minutes without one). Each login has its own cache and backoff files. Concurrent lookups for the same login are coordinated, and a failure does not shorten an existing backoff deadline. Concurrent lookups for different logins retain both cached emails. Existing matching entries in the old shared email cache remain usable. If credentials exist but their email lookup fails or is backed off, the widget is hidden instead of showing an unverified previous account. Without OAuth credentials, it can still show `oauthAccount.emailAddress` from `.claude.json`. Cached email is used only for the current login fingerprint. -Usage API answers are cached for 180 seconds and shared by every session. Values that Claude Code's status line data doesn't include, such as Weekly Fable Usage and the extra usage figures, come only from the usage API, through that cache. When the status line data's 5-hour or weekly reset time is more than a minute later than the cached answer's, that window has reset since the answer was cached, so the render fetches a new one instead of showing the previous window's numbers for the rest of the 180 seconds. While a fetch is already running, or fetches are paused after a failed or rate-limited one, the previous answer stays on screen until the next fetch. If the API still answers with the previous window, ccstatusline asks again at most every 30 seconds until it doesn't. A reset of a window the status line data doesn't include, such as a Fable window ending at a different time from the weekly one, still shows only once the cache expires. +Usage API answers are cached for 180 seconds and shared by every session. Values that Claude Code's status line data doesn't include, such as Weekly Fable Usage and the extra usage figures, come only from the usage API, through that cache. When the status line data's 5-hour or weekly reset time is more than a minute later than the cached answer's, that window has reset since the answer was cached, so the render fetches a new one instead of showing the previous window's numbers for the rest of the 180 seconds. While a fetch is already running, or fetches are paused after a failed or rate-limited one, the previous answer stays on screen until the next fetch, as stale data: any window whose own reset time has passed is left out of it. If the API still answers with the previous window, ccstatusline asks again at most every 30 seconds until it doesn't. A reset of a window the status line data doesn't include, such as a Fable window ending at a different time from the weekly one, still shows only once the cache expires. Today's extra usage spend has to stay with a login all day, but Claude Code saves a new refresh token, and so a new fingerprint, each time it refreshes the access token. So while a widget shows today's spend, ccstatusline also asks `/api/oauth/profile` which account and organization the credentials belong to, once per refresh token, and keeps the answers in `~/.cache/ccstatusline/usage-account.json`. A failed lookup is retried for that login after 30 seconds, or after the `Retry-After` of a 429 response, up to a day (five minutes without one); until it succeeds, the day's record is kept under the token fingerprint and starts over when the token changes.