diff --git a/docs/site/content/docs/agents/usage-tracking.mdx b/docs/site/content/docs/agents/usage-tracking.mdx index f0d9b95daf2..6c1893bfd0e 100644 --- a/docs/site/content/docs/agents/usage-tracking.mdx +++ b/docs/site/content/docs/agents/usage-tracking.mdx @@ -14,7 +14,15 @@ Orca fetches usage for Claude Code, Codex, Gemini, OpenCode Go, Kimi Code, MiniM Orca asks each provider for your usage over the network, using the login that agent already has or a key or session cookie you add in Orca Settings. -For Claude, Orca requests `https://api.anthropic.com/api/oauth/usage` with the token from your existing Claude Code login; it does not ask for extra credentials. Triggers include: +### Claude usage polling + +**Settings → Accounts → Claude → Claude usage polling** is on by default. Turn it off to stop Orca's requests to `https://api.anthropic.com/api/oauth/usage`, including manual refreshes, account switching, inactive-account previews, and refreshes from mobile or web clients. Turning it off cancels pending usage requests and removes their cached figures while retaining live terminal readings. A request already sent cannot be recalled. + +With polling off, the meter uses Claude Code's [status-line data](https://code.claude.com/docs/en/statusline#rate-limit-usage): five-hour and weekly percentages and reset times. This requires a supported Claude Code version and Orca's status-line feed. Orca preserves a user-owned status line, so those sessions do not supply this feed. Without a reading, usage is unavailable until a terminal session reports it. Model-specific weekly windows, credits, and inactive-account readings require polling. + +The persisted setting is `claudeOAuthUsageEnabled: false` to disable polling. It applies on the host running the agents, including a headless Orca server; configure each host separately. Hiding the Claude status-bar item controls visibility independently. + +When polling is enabled, Orca uses the token from your existing Claude Code login. Triggers include: - Once the Orca window is active after startup, then about every 15 minutes while it stays visible and focused. - Bringing the window back to the front when the last check is more than 5 minutes old (sooner after a failed check). diff --git a/src/main/providers/accounts/provider-account-services.ts b/src/main/providers/accounts/provider-account-services.ts index a9cdb080fc9..65bcb2c0ca9 100644 --- a/src/main/providers/accounts/provider-account-services.ts +++ b/src/main/providers/accounts/provider-account-services.ts @@ -69,6 +69,18 @@ export function createProviderAccountServices( ) store.onSettingsChanged((updates, settings) => { void agentView.reconcile(settings) + if (updates.claudeOAuthUsageEnabled === false) { + rateLimits.discardClaudeOAuthUsage() + } else if (updates.claudeOAuthUsageEnabled === true) { + void rateLimits + .refreshClaudeForTarget(rateLimits.getState().claudeTarget) + .catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh Claude usage after enabling polling:', + error + ) + }) + } // Auto runtime selection remains live as provider settings change. void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) @@ -137,6 +149,7 @@ export function createProviderAccountServices( apiKey: readZcodePlanApiKey() ?? '' })) rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + rateLimits.setClaudeOAuthUsageEnabledResolver(() => store.getSettings().claudeOAuthUsageEnabled) // Reuse the meter switch so hidden Antigravity usage does not spawn agy. rateLimits.setAntigravityUsageEnabledResolver(() => store.getUI().statusBarItems.includes('antigravity') diff --git a/src/main/rate-limits/providers/claude/claude-usage-result.ts b/src/main/rate-limits/providers/claude/claude-usage-result.ts index c729e503f78..06b33d52aa0 100644 --- a/src/main/rate-limits/providers/claude/claude-usage-result.ts +++ b/src/main/rate-limits/providers/claude/claude-usage-result.ts @@ -30,6 +30,14 @@ export function makeClaudeUsageResult( } } +// Not idle: the meter draws idle as pending, and nothing here will settle it. +export function claudeOAuthUsageDisabledResult(): ProviderRateLimits { + return makeClaudeUsageResult('unavailable', 'Usage appears when a Claude session runs.', { + failureKind: 'usage-unavailable', + attemptedSources: [] + }) +} + // Account rows explain host/profile failures; usage keeps its generic message. export function claudeUsageUnavailable(): ProviderRateLimits { return makeClaudeUsageResult('error', 'Claude usage is unavailable right now.', { diff --git a/src/main/rate-limits/service/rate-limit-service.ts b/src/main/rate-limits/service/rate-limit-service.ts index 9dcc556cc53..fd199a2bb73 100644 --- a/src/main/rate-limits/service/rate-limit-service.ts +++ b/src/main/rate-limits/service/rate-limit-service.ts @@ -19,6 +19,7 @@ import type { OpenCodeGoRateLimitConfig, MiniMaxRateLimitConfig, AntigravityUsageEnabledResolver, + ClaudeOAuthUsageEnabledResolver, MuseUsageEnabledResolver, ZcodePlanRateLimitConfig, GeminiCliOAuthEnabledResolver, @@ -73,6 +74,27 @@ export class RateLimitService extends RateLimitServiceAccountRefresh { this.geminiCliOAuthEnabledResolver = resolver } + setClaudeOAuthUsageEnabledResolver(resolver: ClaudeOAuthUsageEnabledResolver): void { + this.claudeOAuthUsageEnabledResolver = resolver + } + + // Retain only readings actually received from the terminal feed. + discardClaudeOAuthUsage(): void { + this.claudeUsageAbortController.abort() + this.claudeUsageAbortController = new AbortController() + this.claudeFetchGeneration += 1 + this.inactiveClaudeAccountsGeneration += 1 + this.inactiveClaudeCache.clear() + this.inactiveClaudeFetching.clear() + this.lastInactiveClaudeFetchAt = 0 + this.lastClaudeCheckCompletedAt = null + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ ...this.state, claude: this.claudeUsageWithoutOAuth() }) + if (!this.lastClaudeAuthSnapshot) { + void this.fetchClaudeOnly({ force: true }) + } + } + setAntigravityUsageEnabledResolver(resolver: AntigravityUsageEnabledResolver): void { this.antigravityUsageEnabledResolver = resolver } @@ -162,6 +184,10 @@ export class RateLimitService extends RateLimitServiceAccountRefresh { void this.fetchAll() } else { this.scheduleDeferredStartupRefresh() + // Passive terminal usage needs account attribution even without a usage viewer. + if (!this.isClaudeOAuthUsageEnabled()) { + void this.fetchClaudeOnly({ force: true }) + } } this.startTimer() } diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index fdea05aff72..0cb65b2070f 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -57,6 +57,9 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna provider: 'claude' | 'codex', options: { notWithinMs?: number } = {} ): boolean { + if (provider === 'claude' && !this.isClaudeOAuthUsageEnabled()) { + return false + } const limits = this.state[provider] const now = Date.now() if (this.isFetching || this.isRetryAfterActive(limits)) { @@ -230,6 +233,8 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna this.activeFailureStreakByProvider.claude = 0 // Outgoing statusline posts must not land on the incoming account during the switch. this.lastClaudeAuthSnapshot = null + this.liveClaudeUsage = null + this.usageReadingAccounts.claude = undefined this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, @@ -249,6 +254,8 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna if (targetChanged) { // Outgoing target posts must not land on the incoming target during the switch. this.lastClaudeAuthSnapshot = null + this.liveClaudeUsage = null + this.usageReadingAccounts.claude = undefined } this.updateState({ ...this.state, diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index 8af5c4b22b0..32512ce90c0 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -15,10 +15,24 @@ import { isStaleClaudeUsageWindow, mapClaudeUsageWindow } from '../providers/claude/claude-usage-window' +import { claudeOAuthUsageDisabledResult } from '../providers/claude/claude-usage-result' import { readUserClaudeConfigDir } from '../../claude-accounts/profiles/claude-profile-paths' import { getClaudeProfileRouter } from '../../claude-accounts/profiles/routing/claude-profile-installed-router' export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { + protected isClaudeOAuthUsageEnabled(): boolean { + return this.claudeOAuthUsageEnabledResolver?.() ?? true + } + + protected claudeUsageFetchSignal(signal: AbortSignal): AbortSignal { + return AbortSignal.any([signal, this.claudeUsageAbortController.signal]) + } + + protected claudeUsageWithoutOAuth(): ProviderRateLimits { + const live = this.liveClaudeUsage ? withoutResetWindows(this.liveClaudeUsage, Date.now()) : null + return live?.session || live?.weekly ? live : claudeOAuthUsageDisabledResult() + } + protected getMiniMaxCredentialError(message: string): ProviderRateLimits { return { provider: 'minimax', @@ -44,6 +58,12 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT } } + protected claudeStateAtCycleStart(limits: ProviderRateLimits | null): ProviderRateLimits { + return this.isClaudeOAuthUsageEnabled() + ? this.withFetchingStatus(limits, 'claude') + : this.claudeUsageWithoutOAuth() + } + // Retry-After remains binding even when live posts restore ok display; early polls waste budget and prolong 429s. protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { return Boolean(limits?.usageMetadata?.retryAtMs && limits.usageMetadata.retryAtMs > Date.now()) @@ -70,6 +90,9 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT fresh: ProviderRateLimits, previous: ProviderRateLimits | null ): ProviderRateLimits { + if (!this.isClaudeOAuthUsageEnabled()) { + return this.claudeUsageWithoutOAuth() + } this.lastClaudeCheckCompletedAt = fresh.updatedAt // Failed OAuth must preserve newer live display while forwarding Retry-After to the poll gate. const current = this.state.claude @@ -94,15 +117,23 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT ) { return } - this.lastClaudeAuthSnapshot = { + const snapshot = { // System Default uses the user config folder without injecting a managed one. configDir: normalizeClaudeConfigDir( authPreparation?.envPatch.CLAUDE_CONFIG_DIR ?? getClaudeProfileRouter()?.userConfigDir() ?? readUserClaudeConfigDir(process.env) ), + home: authPreparation?.configDir, provenance: authPreparation?.provenance ?? 'system' } + if ( + snapshot.configDir !== this.lastClaudeAuthSnapshot?.configDir || + snapshot.provenance !== this.lastClaudeAuthSnapshot?.provenance + ) { + this.liveClaudeUsage = null + } + this.lastClaudeAuthSnapshot = snapshot } ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { @@ -155,6 +186,26 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT ) { return } + const priorLive = this.liveClaudeUsage ? withoutResetWindows(this.liveClaudeUsage, now) : null + this.liveClaudeUsage = { + provider: 'claude', + session: freshSession ?? priorLive?.session ?? null, + weekly: freshWeekly ?? priorLive?.weekly ?? null, + updatedAt: now, + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + authProvenance: snapshot.provenance + } + } + this.rememberUsageReadingAccount( + 'claude', + this.claudeFetchTarget, + snapshot.home, + snapshot.provenance + ) if ( previous?.status === 'ok' && previous.usageMetadata?.source === 'live-session' && diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index fc9c8186b05..b9ed5efd4e8 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -153,7 +153,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ this.updateState({ ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude'), + claude: this.claudeStateAtCycleStart(previousState.claude), // A gated slot makes no attempt and cannot settle a fetching chip. codex: codexFetchGated ? codexStateBeforeFetch @@ -220,7 +220,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Live display does not waive automated Retry-After gates. const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedFetch('claude', previousState.claude) + !this.isClaudeOAuthUsageEnabled() || + claudeGeneration !== this.claudeFetchGeneration || + (!options?.force && this.shouldSkipAutomatedFetch('claude', previousState.claude)) const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ @@ -231,7 +233,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), networkProxySettings: this.networkProxySettingsResolver?.(), - signal + signal: this.claudeUsageFetchSignal(signal) }), codexFetchGated ? Promise.resolve(previousState.codex as ProviderRateLimits) diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index ffcaba04f59..45c171dc0e6 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -6,6 +6,9 @@ import { delayUnlessAborted } from '../../../shared/abort-aware-delay' export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling { async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (!this.isClaudeOAuthUsageEnabled()) { + return + } if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } @@ -21,7 +24,7 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP } const fetchGeneration = this.inactiveClaudeAccountsGeneration const controller = this.beginFetchCycle() - const signal = controller.signal + const signal = this.claudeUsageFetchSignal(controller.signal) for (const account of accounts) { this.inactiveClaudeFetching.add(account.id) diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index 0a6e7421d3c..33da0aa57a8 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -87,6 +87,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue const museEnabled = this.museUsageEnabledResolver?.() ?? true return Object.entries(byProvider) .filter(([provider]) => provider !== 'muse' || museEnabled) + .filter(([provider]) => provider !== 'claude' || this.isClaudeOAuthUsageEnabled()) .map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, limits diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index 3218091ead4..42a8be46035 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -94,10 +94,15 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. const claudeGeneration = this.claudeFetchGeneration const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { + if (signal.aborted || claudeGeneration !== this.claudeFetchGeneration) { return } this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + // After the snapshot: statusLine posts are dropped until one names the selected account. + if (!this.isClaudeOAuthUsageEnabled()) { + this.updateState({ ...this.state, claude: this.claudeUsageWithoutOAuth() }) + return + } const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const previousState = this.state @@ -111,7 +116,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), networkProxySettings: this.networkProxySettingsResolver?.(), - signal + signal: this.claudeUsageFetchSignal(signal) }).catch((err): ProviderRateLimits => ({ provider: 'claude', session: null, diff --git a/src/main/rate-limits/service/service-refresh-orchestration.test.ts b/src/main/rate-limits/service/service-refresh-orchestration.test.ts index 6f3ef84e67d..28afdea75ff 100644 --- a/src/main/rate-limits/service/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service/service-refresh-orchestration.test.ts @@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../../shared/rate-limit-types' import { RateLimitService } from './rate-limit-service' import { fetchActiveClaudeRateLimits } from '../providers/claude/claude-active-usage-fetch' +import { fetchInactiveClaudeAccountUsage } from '../providers/claude/claude-managed-account-usage' import { fetchCodexRateLimits } from '../providers/codex/codex-fetcher' import { deferred, flushMicrotasks, + mockFreshBackgroundProviderFetches, okProvider, resetRateLimitProviderMocks } from './rate-limit-service-test-harness' @@ -95,4 +97,213 @@ describe('RateLimitService', () => { await flushMicrotasks(60) expect(service.getState().claude?.session?.usedPercent).toBe(55) }) + + it('keeps live Claude usage through refreshes without reading OAuth usage when disabled', async () => { + mockFreshBackgroundProviderFetches() + const service = new RateLimitService() + service.setClaudeOAuthUsageEnabledResolver(() => false) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'other' }]) + let home = '/claude/account-a' + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + provenance: home + })) + service.start({ fetchImmediately: false }) + await flushMicrotasks(20) + service.stop() + expect(service.getState().claude).toMatchObject({ + session: null, + weekly: null, + status: 'unavailable' + }) + service.ingestLiveClaudeRateLimits({ + configDir: '/claude/account-a', + fiveHour: { used_percentage: 33 }, + sevenDay: { used_percentage: 62 } + }) + await service.refreshClaudeForTarget() + await service.refresh() + await service.fetchInactiveClaudeAccountsOnOpen() + + expect(service.getState().claude).toMatchObject({ + session: { usedPercent: 33 }, + weekly: { usedPercent: 62 }, + status: 'ok', + usageMetadata: { source: 'live-session' } + }) + expect(service.getState().inactiveClaudeAccounts).toEqual([]) + expect(service.usageReadingAccount('claude')).toEqual({ + home: '/claude/account-a', + provenance: '/claude/account-a' + }) + expect(service.requestUsageReading('claude')).toBe(false) + expect(fetchActiveClaudeRateLimits).not.toHaveBeenCalled() + expect(fetchInactiveClaudeAccountUsage).not.toHaveBeenCalled() + + home = '/claude/account-b' + await service.refreshForClaudeAccountChange('outgoing') + expect(service.getState().claude?.session).toBeNull() + expect(service.getState().claude?.status).toBe('unavailable') + service.ingestLiveClaudeRateLimits({ + configDir: '/claude/account-b', + fiveHour: { used_percentage: 12 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(12) + expect(service.usageReadingAccount('claude')).toEqual({ + home: '/claude/account-b', + provenance: '/claude/account-b' + }) + }) + + it('cancels pending Claude usage and rejects its reply even after polling is enabled again', async () => { + mockFreshBackgroundProviderFetches() + const service = new RateLimitService() + let enabled = true + service.setClaudeOAuthUsageEnabledResolver(() => enabled) + const started = deferred() + const reply = deferred() + vi.mocked(fetchActiveClaudeRateLimits).mockImplementationOnce((options) => { + started.resolve(options?.signal) + return reply.promise + }) + const refresh = service.refresh() + const signal = await started.promise + + enabled = false + service.discardClaudeOAuthUsage() + expect(signal?.aborted).toBe(true) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 41 }, + sevenDay: null + }) + enabled = true + reply.resolve(okProvider('claude', 90)) + await refresh + + expect(service.getState().claude).toMatchObject({ + session: { usedPercent: 41 }, + status: 'ok', + usageMetadata: { source: 'live-session' } + }) + }) + + it('retains only terminal-observed windows when disabling OAuth-enriched usage', async () => { + mockFreshBackgroundProviderFetches() + const service = new RateLimitService() + let enabled = true + service.setClaudeOAuthUsageEnabledResolver(() => enabled) + vi.mocked(fetchActiveClaudeRateLimits).mockResolvedValueOnce({ + ...okProvider('claude', 20), + weekly: { usedPercent: 50, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + fableWeekly: { + usedPercent: 70, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + extraUsage: { + unit: 'credits', + enabled: true, + balance: 100, + unlimited: false, + disabledReason: null, + resetsAt: null + } + }) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: { used_percentage: 35 }, + sevenDay: null + }) + expect(service.getState().claude?.weekly?.usedPercent).toBe(50) + + enabled = false + service.discardClaudeOAuthUsage() + await service.refresh() + + expect(service.getState().claude).toMatchObject({ + session: { usedPercent: 35 }, + weekly: null, + usageMetadata: { source: 'live-session' } + }) + expect(service.getState().claude?.fableWeekly).toBeFalsy() + expect(service.getState().claude?.extraUsage).toBeFalsy() + await service.refreshForClaudeAccountChange('outgoing') + expect(service.getState().claude?.session).toBeNull() + }) + + it('discards pending inactive-account usage when polling is disabled', async () => { + const service = new RateLimitService() + let enabled = true + service.setClaudeOAuthUsageEnabledResolver(() => enabled) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'other' }, { id: 'another' }]) + const started = deferred() + const reply = deferred() + vi.mocked(fetchInactiveClaudeAccountUsage).mockImplementationOnce((_account, options) => { + started.resolve(options?.signal) + return reply.promise + }) + const refresh = service.fetchInactiveClaudeAccountsOnOpen() + const signal = await started.promise + + enabled = false + service.discardClaudeOAuthUsage() + expect(signal?.aborted).toBe(true) + enabled = true + reply.resolve(okProvider('claude', 90)) + await refresh + + expect(service.getState().inactiveClaudeAccounts).toEqual([]) + expect(service.getState().claude).toMatchObject({ + session: null, + weekly: null, + status: 'unavailable' + }) + }) + + it.each(['all', 'claude'] as const)( + 'does not revive a canceled %s request still preparing account attribution', + async (cycle) => { + mockFreshBackgroundProviderFetches() + const service = new RateLimitService() + let enabled = true + service.setClaudeOAuthUsageEnabledResolver(() => enabled) + const oldAuth = { configDir: '/claude/old', envPatch: {}, provenance: 'old' } + const preparation = deferred() + service.setClaudeAuthPreparationResolver( + vi + .fn() + .mockReturnValueOnce(preparation.promise) + .mockResolvedValue({ + configDir: '/claude/current', + envPatch: { CLAUDE_CONFIG_DIR: '/claude/current' }, + provenance: 'current' + }) + ) + const refresh = cycle === 'all' ? service.refresh() : service.refreshClaudeForTarget() + enabled = false + service.discardClaudeOAuthUsage() + enabled = true + preparation.resolve(oldAuth) + await refresh + + expect(fetchActiveClaudeRateLimits).not.toHaveBeenCalledWith( + expect.objectContaining({ authPreparation: oldAuth }) + ) + service.ingestLiveClaudeRateLimits({ + configDir: '/claude/current', + fiveHour: { used_percentage: 15 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(15) + expect(service.usageReadingAccount('claude')).toEqual({ + home: '/claude/current', + provenance: 'current' + }) + } + ) }) diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 3b0b3c0d35a..f847392189c 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -17,6 +17,7 @@ import { type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, type AntigravityUsageEnabledResolver, + type ClaudeOAuthUsageEnabledResolver, type MuseUsageEnabledResolver, type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, @@ -90,10 +91,16 @@ export abstract class RateLimitServiceState { protected fetchIdleResolvers: (() => void)[] = [] protected codexFetchGeneration = 0 protected claudeFetchGeneration = 0 + protected claudeUsageAbortController = new AbortController() + protected liveClaudeUsage: ProviderRateLimits | null = null // Why: cached display receipts cannot establish when the selected account was last checked. protected lastClaudeCheckCompletedAt: number | null = null // Capture account attribution once per cycle instead of rerunning auth preparation on every post. - protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null + protected lastClaudeAuthSnapshot: { + configDir: string | null + home: string | undefined + provenance: string + } | null = null /** Where the applied host-runtime Claude/Codex readings were taken. */ protected usageReadingAccounts: Partial< Record<'claude' | 'codex', { home: string; provenance: string }> @@ -122,6 +129,7 @@ export abstract class RateLimitServiceState { protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected claudeOAuthUsageEnabledResolver: ClaudeOAuthUsageEnabledResolver | null = null protected antigravityUsageEnabledResolver: AntigravityUsageEnabledResolver | null = null protected antigravityFetchTarget: LocalAccountRuntimeTarget = { runtime: 'host', wslDistro: null } protected antigravityAccountUsageRecorder: AntigravityAccountUsageRecorder | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index c2c9b9e8c2b..bb67e283a65 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -62,6 +62,7 @@ export type ZcodePlanResolvedConfig = { export type GeminiCliOAuthEnabledResolver = () => boolean export type AntigravityUsageEnabledResolver = () => boolean +export type ClaudeOAuthUsageEnabledResolver = () => boolean export type MuseUsageEnabledResolver = () => boolean export type ActiveRateLimitProvider = ProviderRateLimits['provider'] export type ActiveProviderState = { diff --git a/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx index bc05589e570..9ad23092a2b 100644 --- a/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx @@ -8,6 +8,7 @@ import { ButtonGroup } from '../../../ui/button-group' import { Label } from '../../../ui/label' import { ClaudeIcon } from '../../../status-bar/icons' import { SearchableSetting } from '../../section-layout/SearchableSetting' +import { ClaudeOAuthUsageSetting } from './claude-oauth-usage-setting' import { ClaudeRunningSessionSwitchSetting } from './claude-running-session-switch-setting' import { getProviderAccountRuntime, @@ -45,6 +46,7 @@ export function renderClaudeAccountsSection( claudeAccounts, claudeAction, isRemoteAccountScope, + recordFeatureInteraction, remoteAccountScopeNotice, runClaudeAccountAction, setRemoveClaudeTarget, @@ -383,6 +385,13 @@ export function renderClaudeAccountsSection( )} + {isRemoteAccountScope ? null : ( + + )} {isRemoteAccountScope ? null : ( )} diff --git a/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.fixture.tsx b/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.fixture.tsx new file mode 100644 index 00000000000..f01471848ca --- /dev/null +++ b/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.fixture.tsx @@ -0,0 +1,42 @@ +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { getDefaultSettings } from '../../../../../../shared/constants' +import { useAppStore } from '../../../../store' +import { SettingsGroup } from '../../section-layout/SettingsGroup' +import { ClaudeOAuthUsageSetting } from './claude-oauth-usage-setting' + +function UsageSettingFixture(): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + if (!settings) { + return null + } + return ( +
+ + {}} + /> + +
+ ) +} + +export const on = defineFixture({ width: 720, render: () => }) + +export const off = defineFixture({ + ...on, + store: { settings: { ...getDefaultSettings('/fixture'), claudeOAuthUsageEnabled: false } } +}) + +export const narrow = defineFixture({ ...off, width: 360 }) + +export const infoTooltip = defineFixture({ + ...on, + interact: (frame) => { + frame + .querySelector('button[aria-label="About Claude usage polling"]') + ?.focus() + } +}) diff --git a/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.tsx b/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.tsx new file mode 100644 index 00000000000..5a8dc027100 --- /dev/null +++ b/src/renderer/src/components/settings/accounts/claude/claude-oauth-usage-setting.tsx @@ -0,0 +1,67 @@ +import { useTranslate } from '@/i18n/use-translate' +import { Info } from 'lucide-react' +import { Button } from '../../../ui/button' +import { Label } from '../../../ui/label' +import { Switch } from '../../../ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/tooltip' +import { SearchableSetting } from '../../section-layout/SearchableSetting' +import type { AccountsPaneSectionModel } from '../accounts-pane-types' + +type ClaudeOAuthUsageSettingProps = Pick< + AccountsPaneSectionModel, + 'settings' | 'updateSettings' | 'recordFeatureInteraction' +> + +export function ClaudeOAuthUsageSetting({ + settings, + updateSettings, + recordFeatureInteraction +}: ClaudeOAuthUsageSettingProps): React.JSX.Element { + const t = useTranslate() + const title = t('accounts.claude.oauthUsage.title', 'Claude usage polling') + const description = t( + 'accounts.claude.oauthUsage.description', + 'Uses your Claude Code sign-in to check usage, credits, and inactive accounts. Turn off to use live terminal readings only.' + ) + return ( + +
+ + + + + + + {description} + + +
+ + {description} + + { + recordFeatureInteraction('usage-tracking') + updateSettings({ claudeOAuthUsageEnabled: checked }) + }} + /> +
+ ) +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 6901410c75b..347472857df 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -21958,6 +21958,11 @@ "reasonSharedSession": "Auch in einem anderen Bereich geöffnet", "sharedOne": "1 Claude-Sitzung bleibt auf {{previous}}, weil dieselbe Unterhaltung in einem anderen Bereich geöffnet ist. Ein Neustart könnte die Unterhaltung des anderen Bereichs erneut öffnen.", "sharedOther": "{{count}} Claude-Sitzungen bleiben auf {{previous}}, weil dieselbe Unterhaltung in einem anderen Bereich geöffnet ist. Ein Neustart könnte die Unterhaltung des anderen Bereichs erneut öffnen." + }, + "oauthUsage": { + "title": "Claude-Nutzungsabfrage", + "description": "Verwendet deine Claude-Code-Anmeldung, um Nutzung, Guthaben und inaktive Konten abzufragen. Deaktiviere diese Option, um nur Live-Daten aus Terminalsitzungen zu verwenden.", + "about": "Über die Claude-Nutzungsabfrage" } }, "managedData": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0b43fa80a27..9795bf9aae0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23494,6 +23494,11 @@ "body_one": "{{count}} Claude session is still running on {{previous}}. Restarting continues the conversation on {{next}}. Busy sessions wait for idle before restarting.", "body_other": "{{count}} Claude sessions are still running on {{previous}}. Restarting continues each conversation on {{next}}. Busy sessions wait for idle before restarting.", "restartCount_one": "Restart {{count}} session" + }, + "oauthUsage": { + "title": "Claude usage polling", + "description": "Uses your Claude Code sign-in to check usage, credits, and inactive accounts. Turn off to use live terminal readings only.", + "about": "About Claude usage polling" } }, "managedData": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b5e69c8f6db..f92aa988b2a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -21566,6 +21566,11 @@ "reasonSharedSession": "También abierta en otro panel", "sharedOne": "1 sesión de Claude permanece en {{previous}} porque otro panel tiene abierta la misma conversación. Reiniciarla podría volver a abrir la conversación del otro panel.", "sharedOther": "{{count}} sesiones de Claude permanecen en {{previous}} porque otro panel tiene abierta la misma conversación. Reiniciarlas podría volver a abrir la conversación del otro panel." + }, + "oauthUsage": { + "title": "Consulta de uso de Claude", + "description": "Usa tu inicio de sesión de Claude Code para consultar el uso, los créditos y las cuentas inactivas. Desactívala para usar solo las lecturas en directo de las sesiones de terminal.", + "about": "Acerca de la consulta de uso de Claude" } }, "hostSignIn": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index adbc27e17f3..57e30149881 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -22234,6 +22234,11 @@ "reasonSharedSession": "Également ouverte dans un autre volet", "sharedOne": "1 session Claude reste sur {{previous}} car la même conversation est ouverte dans un autre volet. Un redémarrage pourrait rouvrir la conversation de l’autre volet.", "sharedOther": "{{count}} sessions Claude restent sur {{previous}} car la même conversation est ouverte dans un autre volet. Un redémarrage pourrait rouvrir la conversation de l’autre volet." + }, + "oauthUsage": { + "title": "Suivi de l’utilisation de Claude", + "description": "Utilise votre connexion Claude Code pour consulter l’utilisation, les crédits et les comptes inactifs. Désactivez cette option pour utiliser uniquement les données en direct des sessions de terminal.", + "about": "À propos du suivi de l’utilisation de Claude" } }, "hostSignIn": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c3f93d47ebf..5913dbb8282 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -21561,6 +21561,11 @@ "reasonSharedSession": "別のペインでも開いています", "sharedOne": "別のペインで同じ会話が開いているため、1 件の Claude セッションは {{previous}} のままです。再起動すると別のペインの会話が再び開く可能性があります。", "sharedOther": "別のペインで同じ会話が開いているため、{{count}} 件の Claude セッションは {{previous}} のままです。再起動すると別のペインの会話が再び開く可能性があります。" + }, + "oauthUsage": { + "title": "Claude の使用量の定期取得", + "description": "Claude Code のログイン情報を使って、使用量、クレジット、非アクティブなアカウントを確認します。オフにすると、実行中のターミナルセッションからの使用量データのみを表示します。", + "about": "Claude の使用量の定期取得について" } }, "hostSignIn": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2e312739034..268bfacdbe9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -21553,6 +21553,11 @@ "reasonSharedSession": "다른 패널에서도 열려 있음", "sharedOne": "다른 패널에서 같은 대화를 열고 있어 Claude 세션 1개가 {{previous}}에 유지됩니다. 다시 시작하면 다른 패널의 대화가 다시 열릴 수 있습니다.", "sharedOther": "다른 패널에서 같은 대화를 열고 있어 Claude 세션 {{count}}개가 {{previous}}에 유지됩니다. 다시 시작하면 다른 패널의 대화가 다시 열릴 수 있습니다." + }, + "oauthUsage": { + "title": "Claude 사용량 주기적 조회", + "description": "Claude Code 로그인 정보로 사용량, 크레딧 및 비활성 계정을 확인합니다. 끄면 실행 중인 터미널 세션에서 제공하는 실시간 사용량만 표시합니다.", + "about": "Claude 사용량 주기적 조회 정보" } }, "hostSignIn": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 3e62a8c2f91..fbb12f430ee 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -22931,6 +22931,11 @@ "reasonSharedSession": "Também aberta em outro painel", "sharedOne": "1 sessão do Claude permanece em {{previous}} porque outro painel tem a mesma conversa aberta. Reiniciar pode reabrir a conversa do outro painel.", "sharedOther": "{{count}} sessões do Claude permanecem em {{previous}} porque outro painel tem a mesma conversa aberta. Reiniciar pode reabrir a conversa do outro painel." + }, + "oauthUsage": { + "title": "Consulta de uso do Claude", + "description": "Usa seu login do Claude Code para consultar o uso, os créditos e as contas inativas. Desative para usar apenas as leituras em tempo real das sessões do terminal.", + "about": "Sobre a consulta de uso do Claude" } }, "managedData": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 1af528a9f4c..d7f6518197e 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -22661,7 +22661,12 @@ "sharedOne": "1 сеанс Claude остаётся на {{previous}}, поскольку тот же разговор открыт в другой панели. При перезапуске может снова открыться разговор из другой панели.", "sharedOther": "Сеансы Claude ({{count}}) остаются на {{previous}}, поскольку тот же разговор открыт в другой панели. При перезапуске может снова открыться разговор из другой панели." }, - "unnamedAccount": "Учётная запись Claude" + "unnamedAccount": "Учётная запись Claude", + "oauthUsage": { + "title": "Опрос использования Claude", + "description": "Использует ваш вход в Claude Code для проверки использования, кредитов и неактивных аккаунтов. Отключите, чтобы получать данные только из активных сеансов терминала.", + "about": "Об опросе использования Claude" + } }, "managedData": { "description": "Добавляйте учётные записи этой командой в терминале сервера Orca. Выбор применяется к новым явным запускам агента на этом сервере; прямой SSH-ретранслятор и запуски WSL из Windows используют собственные учётные данные.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 9321f2c34d4..eccd93ad3a8 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -21928,6 +21928,11 @@ "reasonSharedSession": "Başka bir bölmede de açık", "sharedOne": "Aynı konuşma başka bir bölmede açık olduğu için 1 Claude oturumu {{previous}} üzerinde kalır. Yeniden başlatmak diğer bölmedeki konuşmayı yeniden açabilir.", "sharedOther": "Aynı konuşma başka bir bölmede açık olduğu için {{count}} Claude oturumu {{previous}} üzerinde kalır. Yeniden başlatmak diğer bölmedeki konuşmayı yeniden açabilir." + }, + "oauthUsage": { + "title": "Claude kullanımını sorgula", + "description": "Kullanımı, kredileri ve etkin olmayan hesapları kontrol etmek için Claude Code oturumunuzu kullanır. Yalnızca çalışan terminal oturumlarından gelen canlı verileri kullanmak için kapatın.", + "about": "Claude kullanım sorgulaması hakkında" } }, "managedData": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 0d592cf40c5..90b60ca6c21 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -21631,6 +21631,11 @@ "reasonSharedSession": "在另一個窗格中也已開啟", "sharedOne": "由於另一個窗格開啟了相同對話,1 個 Claude 工作階段會保留在 {{previous}}。重新啟動可能會重新開啟另一個窗格的對話。", "sharedOther": "由於另一個窗格開啟了相同對話,{{count}} 個 Claude 工作階段會保留在 {{previous}}。重新啟動可能會重新開啟另一個窗格的對話。" + }, + "oauthUsage": { + "title": "Claude 用量輪詢", + "description": "使用 Claude Code 登入憑證查詢用量、額度和非使用中帳戶。關閉後,僅顯示執行中終端機工作階段提供的即時用量。", + "about": "關於 Claude 用量輪詢" } }, "managedData": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b68f1c671ae..c62d0c81d31 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -21901,6 +21901,11 @@ "reasonSharedSession": "在另一个窗格中也已打开", "sharedOne": "由于另一个窗格打开了同一对话,1 个 Claude 会话将保持在 {{previous}}。重新启动可能会重新打开另一个窗格的对话。", "sharedOther": "由于另一个窗格打开了同一对话,{{count}} 个 Claude 会话将保持在 {{previous}}。重新启动可能会重新打开另一个窗格的对话。" + }, + "oauthUsage": { + "title": "Claude 用量轮询", + "description": "使用 Claude Code 登录凭据检查用量、额度和非活跃账号。关闭后,仅显示运行中终端会话提供的实时用量。", + "about": "关于 Claude 用量轮询" } }, "hostSignIn": { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index fe3326a7219..7403d95fa1c 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -249,6 +249,7 @@ export function buildDefaultSettings(args: { minimaxEndpoint: 'overseas', zcodePlanSite: 'zai', geminiCliOAuthEnabled: false, + claudeOAuthUsageEnabled: true, agentCmdOverrides: {}, // Arguments hold only the user's own text; the permission flag comes from the choice below. agentDefaultArgs: {}, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 0586388fcf9..85f4a02b995 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -375,6 +375,8 @@ export type GlobalSettings = GlobalSettingsBase & { zcodePlanSite?: ZcodePlanSite /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ geminiCliOAuthEnabled: boolean + /** Poll Claude usage with existing CLI credentials. Off uses live terminal readings only. */ + claudeOAuthUsageEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Off keeps each Codex home's history to itself: Orca links no sessions between accounts or