Skip to content
2 changes: 2 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
260 changes: 259 additions & 1 deletion src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface ProbeOptions {
noProxy?: string;
mode?: 'error' | 'status' | 'success' | 'unexpected';
nowMs: number;
payloadResetAt?: Record<string, string>;
pathDir?: string;
requiredFields?: string[];
responseBody?: string;
Expand Down Expand Up @@ -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() };
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading