From e038034929c7defa7b296e86b164b320d1440d00 Mon Sep 17 00:00:00 2001 From: Carter Ross Date: Wed, 11 Mar 2026 02:04:20 -0600 Subject: [PATCH] feat: Add per-account usage caching, session affinity, and account email widget Adds multi-account support so each Claude Code session displays the correct usage stats and account email, even when multiple sessions are logged into different accounts simultaneously. Changes: - Per-token cache files (usage-{hash}.json, profile-{hash}.json) instead of a single global cache, preventing cross-account data bleed - Session affinity (session-tokens.json) pins each session to its starting account; smart conflict resolution detects in-session /login vs cross-session account switches - Shared OAuth credential module (credentials.ts) with macOS keychain discovery for Claude Code v2.x suffixed entries, using execFileSync to avoid shell injection - New account-email widget showing the current account's email address - Profile API integration (/api/oauth/profile) with 24-hour caching Co-Authored-By: Claude Opus 4.6 --- src/ccstatusline.ts | 12 +- src/types/RenderContext.ts | 2 + src/utils/__tests__/usage-fetch.test.ts | 11 +- src/utils/credentials.ts | 132 +++++++++++++++ src/utils/profile-fetch.ts | 212 ++++++++++++++++++++++++ src/utils/profile-prefetch.ts | 21 +++ src/utils/session-affinity.ts | 109 ++++++++++++ src/utils/usage-fetch.ts | 156 +++++++++-------- src/utils/usage-prefetch.ts | 5 +- src/utils/widget-manifest.ts | 3 +- src/widgets/AccountEmail.ts | 67 ++++++++ src/widgets/index.ts | 3 +- 12 files changed, 645 insertions(+), 88 deletions(-) create mode 100644 src/utils/credentials.ts create mode 100644 src/utils/profile-fetch.ts create mode 100644 src/utils/profile-prefetch.ts create mode 100644 src/utils/session-affinity.ts create mode 100644 src/widgets/AccountEmail.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 6fe1c222..44c97027 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -36,6 +36,8 @@ import { getWidgetSpeedWindowSeconds, isWidgetSpeedWindowEnabled } from './utils/speed-window'; +import { prefetchProfileDataIfNeeded } from './utils/profile-prefetch'; +import { resolveSessionAccount } from './utils/session-affinity'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; function hasSessionDurationInStatusJson(data: StatusJSON): boolean { @@ -121,7 +123,14 @@ async function renderMultipleLines(data: StatusJSON) { sessionDuration = await getSessionDuration(data.transcript_path); } - const usageData = await prefetchUsageDataIfNeeded(lines); + // Resolve session-pinned account — each session keeps its original account's + // data. Pin is cleared by the hook handler when /login is detected. + const session = resolveSessionAccount(data.session_id); + + const [usageData, profileData] = await Promise.all([ + prefetchUsageDataIfNeeded(lines, session), + prefetchProfileDataIfNeeded(lines, session) + ]); let speedMetrics: SpeedMetrics | null = null; let windowedSpeedMetrics: Record | null = null; @@ -147,6 +156,7 @@ async function renderMultipleLines(data: StatusJSON) { speedMetrics, windowedSpeedMetrics, usageData, + profileData, sessionDuration, skillsMetrics, isPreview: false diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9ba86aea..6a8ca9b5 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -3,6 +3,7 @@ import type { SkillsMetrics } from '../types'; +import type { ProfileData } from '../utils/profile-fetch'; import type { SpeedMetrics } from './SpeedMetrics'; import type { StatusJSON } from './StatusJSON'; import type { TokenMetrics } from './TokenMetrics'; @@ -25,6 +26,7 @@ export interface RenderContext { speedMetrics?: SpeedMetrics | null; windowedSpeedMetrics?: Record | null; usageData?: RenderUsageData | null; + profileData?: ProfileData | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; skillsMetrics?: SkillsMetrics | null; diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index b93154bc..682af178 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -129,8 +129,10 @@ https.request = (...args) => { const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)}); -const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock'); -const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json'); +import { createHash } from 'crypto'; +const tokenHash = createHash('sha256').update('test-token').digest('hex').slice(0, 8); +const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + tokenHash + '.lock'); +const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage-' + tokenHash + '.json'); const nowMs = Number(process.env.TEST_NOW_MS || Date.now()); Date.now = () => nowMs; @@ -167,7 +169,7 @@ process.stdout.write(JSON.stringify({ fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(claudeConfig, { recursive: true }); - fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); + fs.writeFileSync(securityScript, '#!/bin/sh\nif [ "$1" = "dump-keychain" ]; then\n echo \' "svce"="Claude Code-credentials"\'\nelse\n echo \'{"claudeAiOauth":{"accessToken":"test-token","expiresAt":9999999999}}\'\nfi\n'); fs.chmodSync(securityScript, 0o755); fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); @@ -574,7 +576,8 @@ describe('fetchUsageData error handling', () => { try { const home = harness.createTokenHome('legacy-lock'); const lockDir = path.join(home.home, '.cache', 'ccstatusline'); - const lockFile = path.join(lockDir, 'usage.lock'); + const testTokenHash = require('crypto').createHash('sha256').update('test-token').digest('hex').slice(0, 8); + const lockFile = path.join(lockDir, `usage-${testTokenHash}.lock`); fs.mkdirSync(lockDir, { recursive: true }); fs.writeFileSync(lockFile, ''); diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts new file mode 100644 index 00000000..74e2bafc --- /dev/null +++ b/src/utils/credentials.ts @@ -0,0 +1,132 @@ +import { createHash } from 'crypto'; +import { execFileSync, execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { z } from 'zod'; + +import { getClaudeConfigDir } from './claude-settings'; + +const TOKEN_CACHE_MAX_AGE = 30; // seconds — short so /login changes are picked up quickly + +const CredentialsSchema = z.object({ + claudeAiOauth: z.object({ + accessToken: z.string().nullable().optional(), + expiresAt: z.number().nullable().optional() + }).optional() +}); + +/** + * Compute a short hash of an OAuth token, used as a cache-file suffix + * so that each account's data is stored separately. + */ +export function oauthTokenHash(token: string): string { + return createHash('sha256').update(token).digest('hex').slice(0, 8); +} + +// Module-level token cache shared across all consumers +let cachedToken: string | null = null; +let tokenCacheTime = 0; + +function parseAccessToken(rawJson: string): string | null { + try { + const parsed = CredentialsSchema.safeParse(JSON.parse(rawJson)); + return parsed.success ? (parsed.data?.claudeAiOauth?.accessToken ?? null) : null; + } catch { + return null; + } +} + +function parseExpiresAt(rawJson: string): number { + try { + const parsed = CredentialsSchema.safeParse(JSON.parse(rawJson)); + return parsed.success ? (parsed.data?.claudeAiOauth?.expiresAt ?? 0) : 0; + } catch { + return 0; + } +} + +/** + * Find the freshest Claude Code credential entry in the macOS keychain. + * Claude Code v2.x uses installation-suffixed keychain entries + * (e.g. "Claude Code-credentials-fe5233b0") that differ per install. + * We scan all matching entries and pick the one with the latest expiresAt. + */ +function getMacOSToken(): string | null { + try { + const dumpOutput = execSync( + 'security dump-keychain 2>/dev/null', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024 } + ); + + const entryNames: string[] = []; + for (const line of dumpOutput.split('\n')) { + const match = line.match(/"svce"="(Claude Code-credentials[^"]*)"/); + if (match?.[1]) { + entryNames.push(match[1]); + } + } + + if (entryNames.length === 0) { + return null; + } + + let bestToken: string | null = null; + let bestExpires = 0; + + for (const entry of entryNames) { + try { + const creds = execFileSync( + 'security', + ['find-generic-password', '-s', entry, '-w'], + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ).trim(); + + const expires = parseExpiresAt(creds); + if (expires > bestExpires) { + bestExpires = expires; + bestToken = parseAccessToken(creds); + } + } catch { + continue; + } + } + + return bestToken; + } catch { + return null; + } +} + +/** + * Get a valid OAuth access token for the Anthropic API. + * On macOS, scans all keychain entries and picks the freshest. + * On other platforms, reads from the credentials file. + * Results are cached in memory for 1 hour. + */ +export function getOAuthToken(): string | null { + const now = Math.floor(Date.now() / 1000); + + if (cachedToken && (now - tokenCacheTime) < TOKEN_CACHE_MAX_AGE) { + return cachedToken; + } + + try { + const isMac = process.platform === 'darwin'; + let token: string | null = null; + + if (isMac) { + token = getMacOSToken(); + } else { + const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); + token = parseAccessToken(fs.readFileSync(credFile, 'utf8')); + } + + if (token) { + cachedToken = token; + tokenCacheTime = now; + } + return token; + } catch { + return null; + } +} diff --git a/src/utils/profile-fetch.ts b/src/utils/profile-fetch.ts new file mode 100644 index 00000000..ddaf4386 --- /dev/null +++ b/src/utils/profile-fetch.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import * as os from 'os'; +import * as path from 'path'; +import { z } from 'zod'; + +import { getOAuthToken, oauthTokenHash } from './credentials'; +import type { SessionAccount } from './session-affinity'; + +export interface ProfileData { + email?: string; + fullName?: string; + displayName?: string; + organizationName?: string; + error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error'; +} + +// Cache configuration — profile data changes rarely +const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline'); +const CACHE_MAX_AGE = 86400; // 24 hours + +function getProfileCacheFile(hash: string): string { + return path.join(CACHE_DIR, `profile-${hash}.json`); +} + +const PROFILE_API_HOST = 'api.anthropic.com'; +const PROFILE_API_PATH = '/api/oauth/profile'; +const PROFILE_API_TIMEOUT_MS = 5000; + +const ProfileApiResponseSchema = z.object({ + account: z.object({ + email: z.string().nullable().optional(), + full_name: z.string().nullable().optional(), + display_name: z.string().nullable().optional() + }).optional(), + organization: z.object({ + name: z.string().nullable().optional() + }).optional() +}); + +const CachedProfileDataSchema = z.object({ + email: z.string().nullable().optional(), + fullName: z.string().nullable().optional(), + displayName: z.string().nullable().optional(), + organizationName: z.string().nullable().optional() +}); + +// Memory cache — invalidated when token changes (different account) +let cachedProfileData: ProfileData | null = null; +let profileCacheTime = 0; +let lastUsedTokenHash: string | null = null; + +function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null { + try { + const parsed = schema.safeParse(JSON.parse(rawJson)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function ensureCacheDirExists(): void { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +function getProxyUrl(): string | null { + const proxyUrl = process.env.HTTPS_PROXY?.trim(); + return proxyUrl || null; +} + +async function fetchFromProfileApi(token: string): Promise<{ kind: 'success'; body: string } | { kind: 'error' }> { + return new Promise((resolve) => { + let settled = false; + const finish = (value: { kind: 'success'; body: string } | { kind: 'error' }) => { + if (settled) return; + settled = true; + resolve(value); + }; + + const proxyUrl = getProxyUrl(); + const requestOptions: https.RequestOptions = { + hostname: PROFILE_API_HOST, + path: PROFILE_API_PATH, + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20' + }, + timeout: PROFILE_API_TIMEOUT_MS, + ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + }; + + const request = https.request(requestOptions, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { data += chunk; }); + response.on('end', () => { + if (response.statusCode === 200 && data) { + finish({ kind: 'success', body: data }); + } else { + finish({ kind: 'error' }); + } + }); + }); + + request.on('error', () => { finish({ kind: 'error' }); }); + request.on('timeout', () => { request.destroy(); finish({ kind: 'error' }); }); + request.end(); + }); +} + +export async function fetchProfileData(session?: SessionAccount): Promise { + const now = Math.floor(Date.now() / 1000); + + // Use session-pinned account if provided, otherwise fall back to current token + let token: string | null; + let hash: string; + let canFetch: boolean; + + if (session) { + token = session.token; + hash = session.hash; + canFetch = session.canFetch; + } else { + token = getOAuthToken(); + hash = token ? oauthTokenHash(token) : ''; + canFetch = true; + } + + if (!hash) { + return { error: 'no-credentials' }; + } + + const cacheFile = getProfileCacheFile(hash); + + // Invalidate memory cache if token changed (different account) + if (lastUsedTokenHash && lastUsedTokenHash !== hash) { + cachedProfileData = null; + profileCacheTime = 0; + } + lastUsedTokenHash = hash; + + // Check memory cache + if (cachedProfileData && !cachedProfileData.error && (now - profileCacheTime) < CACHE_MAX_AGE) { + return cachedProfileData; + } + + // Check per-token file cache (no age limit when we can't fetch — serve stale) + try { + const stat = fs.statSync(cacheFile); + const fileAge = now - Math.floor(stat.mtimeMs / 1000); + if (!canFetch || fileAge < CACHE_MAX_AGE) { + const raw = fs.readFileSync(cacheFile, 'utf8'); + const parsed = parseJsonWithSchema(raw, CachedProfileDataSchema); + if (parsed?.email) { + const data: ProfileData = { + email: parsed.email ?? undefined, + fullName: parsed.fullName ?? undefined, + displayName: parsed.displayName ?? undefined, + organizationName: parsed.organizationName ?? undefined + }; + cachedProfileData = data; + profileCacheTime = now; + return data; + } + } + } catch { + // Continue to API + } + + // Can't make API calls — session is pinned to a different account + if (!canFetch || !token) { + return cachedProfileData ?? { error: 'no-credentials' }; + } + + // Fetch from API + try { + const response = await fetchFromProfileApi(token); + if (response.kind === 'error') { + return cachedProfileData ?? { error: 'api-error' }; + } + + const parsed = parseJsonWithSchema(response.body, ProfileApiResponseSchema); + if (!parsed?.account?.email) { + return cachedProfileData ?? { error: 'parse-error' }; + } + + const profileData: ProfileData = { + email: parsed.account.email ?? undefined, + fullName: parsed.account.full_name ?? undefined, + displayName: parsed.account.display_name ?? undefined, + organizationName: parsed.organization?.name ?? undefined + }; + + // Save to per-token cache file + try { + ensureCacheDirExists(); + fs.writeFileSync(cacheFile, JSON.stringify(profileData)); + } catch { + // Ignore + } + + cachedProfileData = profileData; + profileCacheTime = now; + return profileData; + } catch { + return cachedProfileData ?? { error: 'api-error' }; + } +} diff --git a/src/utils/profile-prefetch.ts b/src/utils/profile-prefetch.ts new file mode 100644 index 00000000..eed800f9 --- /dev/null +++ b/src/utils/profile-prefetch.ts @@ -0,0 +1,21 @@ +import type { WidgetItem } from '../types/Widget'; + +import type { ProfileData } from './profile-fetch'; +import { fetchProfileData } from './profile-fetch'; +import type { SessionAccount } from './session-affinity'; + +const PROFILE_WIDGET_TYPES = new Set([ + 'account-email' +]); + +export function hasProfileDependentWidgets(lines: WidgetItem[][]): boolean { + return lines.some(line => line.some(item => PROFILE_WIDGET_TYPES.has(item.type))); +} + +export async function prefetchProfileDataIfNeeded(lines: WidgetItem[][], session?: SessionAccount): Promise { + if (!hasProfileDependentWidgets(lines)) { + return null; + } + + return await fetchProfileData(session); +} diff --git a/src/utils/session-affinity.ts b/src/utils/session-affinity.ts new file mode 100644 index 00000000..7c6f2231 --- /dev/null +++ b/src/utils/session-affinity.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { getOAuthToken, oauthTokenHash } from './credentials'; + +const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline'); +const SESSION_FILE = path.join(CACHE_DIR, 'session-tokens.json'); +const SESSION_MAX_AGE = 7 * 86400; // prune entries older than 7 days + +interface SessionEntry { + hash: string; + ts: number; +} + +export interface SessionAccount { + /** Token hash to use for cache file lookups */ + hash: string; + /** The actual OAuth token (null if account switched and old token is gone) */ + token: string | null; + /** Whether we can make API calls (false when session is pinned to a different account) */ + canFetch: boolean; +} + +function readSessionMap(): Record { + try { + return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); + } catch { + return {}; + } +} + +function writeSessionMap(map: Record): void { + try { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } + fs.writeFileSync(SESSION_FILE, JSON.stringify(map)); + } catch { + // ignore write errors + } +} + +/** + * Resolve which account (token hash) a session should use. + * + * On first call for a session, pins the current token's hash to that session. + * On subsequent calls, if the keychain token has changed: + * - If another session already claims the new hash → keep original pin (cross-session login) + * - If no other session claims it → adopt the new hash (this session did /login) + */ +export function resolveSessionAccount(sessionId: string | undefined): SessionAccount { + const token = getOAuthToken(); + + if (!token) { + return { hash: '', token: null, canFetch: false }; + } + + const currentHash = oauthTokenHash(token); + + if (!sessionId) { + return { hash: currentHash, token, canFetch: true }; + } + + const now = Math.floor(Date.now() / 1000); + const map = readSessionMap(); + + // Prune old entries + for (const [id, entry] of Object.entries(map)) { + if (now - entry.ts > SESSION_MAX_AGE) { + delete map[id]; + } + } + + const existing = map[sessionId]; + + if (!existing) { + // First invocation for this session — pin current account + map[sessionId] = { hash: currentHash, ts: now }; + writeSessionMap(map); + return { hash: currentHash, token, canFetch: true }; + } + + // Update timestamp to keep entry alive + existing.ts = now; + + if (existing.hash === currentHash) { + // Same account — normal operation + writeSessionMap(map); + return { hash: currentHash, token, canFetch: true }; + } + + // Token changed — determine if this session or another one logged in. + // If another session already claims the new hash, the login happened there. + const claimedByOther = Object.entries(map).some( + ([id, entry]) => id !== sessionId && entry.hash === currentHash + ); + + if (claimedByOther) { + // Another session owns this token — keep our original pin, serve from cache + writeSessionMap(map); + return { hash: existing.hash, token: null, canFetch: false }; + } + + // No other session claims this token — this session just logged in + existing.hash = currentHash; + writeSessionMap(map); + return { hash: currentHash, token, canFetch: true }; +} diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index bb78e0c8..beb469f6 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -1,4 +1,3 @@ -import { execSync } from 'child_process'; import * as fs from 'fs'; import * as https from 'https'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -6,7 +5,8 @@ import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; -import { getClaudeConfigDir } from './claude-settings'; +import { getOAuthToken, oauthTokenHash } from './credentials'; +import type { SessionAccount } from './session-affinity'; import type { UsageData, UsageError @@ -15,14 +15,17 @@ import { UsageErrorSchema } from './usage-types'; // Cache configuration const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); -const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock'); const CACHE_MAX_AGE = 180; // seconds + +function getUsageCacheFile(hash: string): string { + return path.join(CACHE_DIR, `usage-${hash}.json`); +} + +function getUsageLockFile(hash: string): string { + return path.join(CACHE_DIR, `usage-${hash}.lock`); +} const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds -const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour - -const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']); const UsageLockSchema = z.object({ blockedUntil: z.number(), @@ -67,11 +70,6 @@ function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null } } -function parseUsageAccessToken(rawJson: string): string | null { - const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema); - return parsed?.claudeAiOauth?.accessToken ?? null; -} - function parseCachedUsageData(rawJson: string): UsageData | null { const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema); if (!parsed) { @@ -111,12 +109,11 @@ function parseUsageApiResponse(rawJson: string): UsageData | null { }; } -// Memory caches +// Memory caches — invalidated when token changes (different account) let cachedUsageData: UsageData | null = null; let usageCacheTime = 0; -let cachedUsageToken: string | null = null; -let usageTokenCacheTime = 0; let usageErrorCacheMaxAge = LOCK_MAX_AGE; +let lastUsedUsageTokenHash: string | null = null; type UsageLockError = z.infer; @@ -143,73 +140,47 @@ function cacheUsageData(data: UsageData, now: number): UsageData { return data; } -function getStaleUsageOrError(error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData { - const stale = readStaleUsageCache(); +function getStaleUsageOrError(hash: string, error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData { + const stale = readStaleUsageCache(hash); if (stale && !stale.error) { return cacheUsageData(stale, now); } return setCachedUsageError(error, now, errorCacheMaxAge); } +// Delegate to shared credential module which handles suffixed keychain entries function getUsageToken(): string | null { - const now = Math.floor(Date.now() / 1000); - - // Return cached token if still valid - if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) { - return cachedUsageToken; - } - - try { - const isMac = process.platform === 'darwin'; - if (isMac) { - // macOS: read from keychain - const result = execSync( - 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } - ).trim(); - const token = parseUsageAccessToken(result); - if (token) { - cachedUsageToken = token; - usageTokenCacheTime = now; - } - return token; - } - - // Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR - const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); - const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8')); - if (token) { - cachedUsageToken = token; - usageTokenCacheTime = now; - } - return token; - } catch { - return null; - } + return getOAuthToken(); } -function readStaleUsageCache(): UsageData | null { +function readStaleUsageCache(hash: string): UsageData | null { try { - return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); + return parseCachedUsageData(fs.readFileSync(getUsageCacheFile(hash), 'utf8')); } catch { - return null; + // Also try the legacy global file as fallback + try { + return parseCachedUsageData(fs.readFileSync(path.join(CACHE_DIR, 'usage.json'), 'utf8')); + } catch { + return null; + } } } -function writeUsageLock(blockedUntil: number, error: UsageLockError): void { +function writeUsageLock(hash: string, blockedUntil: number, error: UsageLockError): void { try { ensureCacheDirExists(); - fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error })); + fs.writeFileSync(getUsageLockFile(hash), JSON.stringify({ blockedUntil, error })); } catch { // Ignore lock file errors } } -function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null { +function readActiveUsageLock(hash: string, now: number): { blockedUntil: number; error: UsageLockError } | null { + const lockFile = getUsageLockFile(hash); let hasValidJsonLock = false; try { - const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema); + const parsed = parseJsonWithSchema(fs.readFileSync(lockFile, 'utf8'), UsageLockSchema); if (parsed) { hasValidJsonLock = true; if (parsed.blockedUntil > now) { @@ -229,7 +200,7 @@ function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageL } try { - const lockStat = fs.statSync(LOCK_FILE); + const lockStat = fs.statSync(lockFile); const lockMtime = Math.floor(lockStat.mtimeMs / 1000); const blockedUntil = lockMtime + LOCK_MAX_AGE; if (blockedUntil > now) { @@ -352,10 +323,36 @@ async function fetchFromUsageApi(token: string): Promise { }); } -export async function fetchUsageData(): Promise { +export async function fetchUsageData(session?: SessionAccount): Promise { const now = Math.floor(Date.now() / 1000); - // Check memory cache (fast path) + // Use session-pinned account if provided, otherwise fall back to current token + let token: string | null; + let hash: string; + let canFetch: boolean; + + if (session) { + token = session.token; + hash = session.hash; + canFetch = session.canFetch; + } else { + token = getUsageToken(); + hash = token ? oauthTokenHash(token) : ''; + canFetch = true; + } + + if (!hash) { + return setCachedUsageError('no-credentials', now); + } + + // Invalidate memory cache if token changed (different account) + if (lastUsedUsageTokenHash && lastUsedUsageTokenHash !== hash) { + cachedUsageData = null; + usageCacheTime = 0; + } + lastUsedUsageTokenHash = hash; + + // Re-check memory cache after potential invalidation if (cachedUsageData) { const cacheAge = now - usageCacheTime; if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) { @@ -366,12 +363,13 @@ export async function fetchUsageData(): Promise { } } - // Check file cache + // Check per-token file cache (no age limit when we can't fetch — serve stale) + const cacheFile = getUsageCacheFile(hash); try { - const stat = fs.statSync(CACHE_FILE); + const stat = fs.statSync(cacheFile); const fileAge = now - Math.floor(stat.mtimeMs / 1000); - if (fileAge < CACHE_MAX_AGE) { - const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); + if (!canFetch || fileAge < CACHE_MAX_AGE) { + const fileData = parseCachedUsageData(fs.readFileSync(cacheFile, 'utf8')); if (fileData && !fileData.error) { return cacheUsageData(fileData, now); } @@ -380,56 +378,56 @@ export async function fetchUsageData(): Promise { // File doesn't exist or read error - continue to API call } - // Get token before lock/rate-limit checks so auth failures are not masked as timeout. - const token = getUsageToken(); - if (!token) { - return getStaleUsageOrError('no-credentials', now); + // Can't make API calls — session is pinned to a different account + if (!canFetch || !token) { + return getStaleUsageOrError(hash, 'no-credentials', now); } - const activeLock = readActiveUsageLock(now); + const activeLock = readActiveUsageLock(hash, now); if (activeLock) { return getStaleUsageOrError( + hash, activeLock.error, now, Math.max(1, activeLock.blockedUntil - now) ); } - writeUsageLock(now + LOCK_MAX_AGE, 'timeout'); + writeUsageLock(hash, now + LOCK_MAX_AGE, 'timeout'); // Fetch from API using Node's https module try { const response = await fetchFromUsageApi(token); if (response.kind === 'rate-limited') { - writeUsageLock(now + response.retryAfterSeconds, 'rate-limited'); - return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds); + writeUsageLock(hash, now + response.retryAfterSeconds, 'rate-limited'); + return getStaleUsageOrError(hash, 'rate-limited', now, response.retryAfterSeconds); } if (response.kind === 'error') { - return getStaleUsageOrError('api-error', now); + return getStaleUsageOrError(hash, 'api-error', now); } const usageData = parseUsageApiResponse(response.body); if (!usageData) { - return getStaleUsageOrError('parse-error', now); + return getStaleUsageOrError(hash, 'parse-error', now); } // Validate we got actual data if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) { - return getStaleUsageOrError('parse-error', now); + return getStaleUsageOrError(hash, 'parse-error', now); } - // Save to cache + // Save to per-token cache try { ensureCacheDirExists(); - fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData)); + fs.writeFileSync(getUsageCacheFile(hash), JSON.stringify(usageData)); } catch { // Ignore cache write errors } return cacheUsageData(usageData, now); } catch { - return getStaleUsageOrError('parse-error', now); + return getStaleUsageOrError(hash, 'parse-error', now); } } \ No newline at end of file diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts index 9b6e9d9d..687dba75 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -1,5 +1,6 @@ import type { WidgetItem } from '../types/Widget'; +import type { SessionAccount } from './session-affinity'; import type { UsageData } from './usage'; import { fetchUsageData } from './usage'; @@ -15,10 +16,10 @@ export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean { return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type))); } -export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][]): Promise { +export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], session?: SessionAccount): Promise { if (!hasUsageDependentWidgets(lines)) { return null; } - return await fetchUsageData(); + return await fetchUsageData(session); } \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index ce85faf1..938971d5 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -53,7 +53,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() }, { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, { type: 'skills', create: () => new widgets.SkillsWidget() }, - { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() } + { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, + { type: 'account-email', create: () => new widgets.AccountEmailWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/AccountEmail.ts b/src/widgets/AccountEmail.ts new file mode 100644 index 00000000..a74e5440 --- /dev/null +++ b/src/widgets/AccountEmail.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +/** + * Read the most recent profile cache file synchronously. + * This ensures the email is available even if the async prefetch + * hasn't completed (e.g., Claude Code's status line timeout). + */ +function readCachedEmail(): string | null { + try { + const cacheDir = path.join(os.homedir(), '.cache', 'ccstatusline'); + const files = fs.readdirSync(cacheDir).filter(f => f.startsWith('profile-') && f.endsWith('.json')); + if (files.length === 0) return null; + + // Sort by modification time (newest first) so we deterministically + // pick the most recently updated account's email. + const sorted = files + .map(f => ({ name: f, mtime: fs.statSync(path.join(cacheDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + + for (const { name } of sorted) { + const raw = fs.readFileSync(path.join(cacheDir, name), 'utf8'); + const parsed = JSON.parse(raw); + if (parsed?.email) { + return parsed.email; + } + } + } catch { + // No cache available + } + return null; +} + +export class AccountEmailWidget implements Widget { + getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Shows the account email address from your Claude OAuth profile'; } + getDisplayName(): string { return 'Account Email'; } + getCategory(): string { return 'Account'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? 'user@example.com' : 'Account: user@example.com'; + } + + const email = context.profileData?.email ?? readCachedEmail(); + if (!email) { + return null; + } + + return item.rawValue ? email : `Account: ${email}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 3251b5f2..8809bcd4 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -34,4 +34,5 @@ export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; -export { ThinkingEffortWidget } from './ThinkingEffort'; \ No newline at end of file +export { ThinkingEffortWidget } from './ThinkingEffort'; +export { AccountEmailWidget } from './AccountEmail'; \ No newline at end of file