diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9475f57a0..332ea7c43 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -99,6 +99,8 @@ 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, 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 - **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. Cumulative token totals deduplicate nonempty `message.id` values across the transcript, retaining the first counted input/cache snapshot and the maximum output count; records without IDs count independently. Speed collectors also deduplicate response IDs per transcript, use the latest input count and maximum output count, and keep separate active intervals for interleaved records so idle gaps do not become processing time. Session names prefer the latest valid `custom-title`, falling back to the latest valid `ai-title`. diff --git a/docs/USAGE.md b/docs/USAGE.md index 2496063ec..9c043eb08 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -220,6 +220,8 @@ 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, 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. ### Proxy Settings diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index ae6ed24d4..05eb9d8fd 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -74,6 +74,7 @@ interface ProbeOptions { noProxy?: string; mode?: 'error' | 'status' | 'success' | 'unexpected'; nowMs: number; + payloadResetAt?: Record; pathDir?: string; requiredFields?: string[]; responseBody?: string; @@ -219,13 +220,14 @@ const probeCacheDir = path.join(os.homedir(), '.cache', 'ccstatusline'); const accountCacheFile = path.join(probeCacheDir, 'usage-account.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 fetchOnce = async () => { if (process.env.TEST_PROBE_TARGET === 'account-email') { return fetchAccountEmail(); } - const usageData = await fetchUsageData({ requiredFields }); + const usageData = await fetchUsageData({ requiredFields, payloadResetAt }); if (process.env.TEST_PROBE_TARGET === 'usage-account-and-email') { return { account: await lookUpUsageAccount(), accountEmail: await fetchAccountEmail() }; } @@ -368,6 +370,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; } @@ -571,6 +577,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 @@ -1941,6 +1975,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, { ...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, { + 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, { ...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(usageFilePath(home, '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(usageFilePath(home, '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, { ...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, { ...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 205648975..c77d5ba99 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -251,7 +251,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([ @@ -362,7 +400,7 @@ describe('usage prefetch', () => { weeklyResetAt: epochToIso(1774540000) }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionResetAt'] }] + [{ requiredFields: ['sessionResetAt'], payloadResetAt: { weeklyResetAt: epochToIso(1774540000) } }] ]); }); @@ -421,7 +459,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) } }] ]); }); @@ -434,7 +472,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) } }] ]); }); @@ -465,7 +503,7 @@ describe('usage prefetch', () => { expect(usageData?.sessionUsage).toBe(100); expect(usageData?.apiSessionUsage).toBe(100); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionUsage'] }] + [{ requiredFields: ['sessionUsage'], payloadResetAt: { sessionResetAt: epochToIso(1774020000) } }] ]); }); @@ -494,7 +532,7 @@ describe('usage prefetch', () => { // The usage widgets keep the payload's percent; only the hide state reads the API's expect(usageData).toEqual({ sessionUsage: 97, sessionResetAt: epochToIso(1774020000), apiSessionUsage: 100 }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionUsage'] }] + [{ requiredFields: ['sessionUsage'], payloadResetAt: { sessionResetAt: epochToIso(1774020000) } }] ]); }); @@ -595,7 +633,7 @@ describe('usage prefetch', () => { // The widget keeps showing the payload's percent; only the hide state reads the API's expect(usageData).toEqual({ sessionUsage: 97, sessionResetAt: epochToIso(1774020000), apiSessionUsage: 100 }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionUsage'] }] + [{ requiredFields: ['sessionUsage'], payloadResetAt: { sessionResetAt: epochToIso(1774020000) } }] ]); }); @@ -611,7 +649,7 @@ describe('usage prefetch', () => { expect(usageData?.apiSessionUsage).toBe(100); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionUsage'] }] + [{ requiredFields: ['sessionUsage'], payloadResetAt: { sessionResetAt: epochToIso(1774020000) } }] ]); }); @@ -698,7 +736,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 () => { @@ -715,7 +756,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 () => { @@ -854,9 +898,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 f917bf417..5b180443e 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -55,7 +55,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). @@ -458,6 +474,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, @@ -1027,7 +1067,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) { @@ -1058,7 +1098,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, stat.mtimeMs); } } @@ -1117,9 +1157,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 dda1ab019..44c13fdd2 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -11,7 +11,10 @@ import { import { observeExtraUsageSpend } from './daily-spend'; import { getLastResponseAtMs } from './session-usage-history'; -import type { UsageData } from './usage'; +import type { + PayloadResetAt, + UsageData +} from './usage'; import { fetchUsageData, getUsageAccountKey, @@ -392,6 +395,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; @@ -413,7 +429,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 hasAnyUsageDataField(apiData) ? mergeUsageData(rateLimitsData, { ...apiData, error: undefined }) diff --git a/src/utils/usage.ts b/src/utils/usage.ts index fb90b7e38..beb168568 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -6,6 +6,7 @@ export { getUsageLoginFingerprint, isStaleUsageFallback, lookUpUsageAccount, + type PayloadResetAt, type UsageAccount } from './usage-fetch'; export {