feat(usage): add a Claude usage polling switch (#41921)

Add an on-by-default Claude usage polling switch with live-terminal-only behavior when disabled, cancellation and account attribution safeguards, and translations for all supported locales.

Fixes #27151.
Fixes #39502.
Fixes #37037.

Co-authored-by: Sibyl <scauyb@qq.com>
This commit is contained in:
Neil
2026-11-13 01:32:11 -08:00
committed by GitHub
co-authored by Sibyl
parent e4eecd847c
commit 616a581575
29 changed files with 530 additions and 10 deletions
@@ -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).
@@ -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')
@@ -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.', {
@@ -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()
}
@@ -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,
@@ -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' &&
@@ -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)
@@ -6,6 +6,9 @@ import { delayUnlessAborted } from '../../../shared/abort-aware-delay'
export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling {
async fetchInactiveClaudeAccountsOnOpen(): Promise<void> {
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)
@@ -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
@@ -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,
@@ -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<AbortSignal | undefined>()
const reply = deferred<ProviderRateLimits>()
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<AbortSignal | undefined>()
const reply = deferred<ProviderRateLimits>()
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<typeof oldAuth>()
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'
})
}
)
})
@@ -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
@@ -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 = {
@@ -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(
)}
</div>
</SearchableSetting>
{isRemoteAccountScope ? null : (
<ClaudeOAuthUsageSetting
settings={settings}
updateSettings={updateSettings}
recordFeatureInteraction={recordFeatureInteraction}
/>
)}
{isRemoteAccountScope ? null : (
<ClaudeRunningSessionSwitchSetting settings={settings} updateSettings={updateSettings} />
)}
@@ -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 (
<div className="settings-view-shell bg-background p-8 text-foreground">
<SettingsGroup title="Claude">
<ClaudeOAuthUsageSetting
settings={settings}
updateSettings={updateSettings}
recordFeatureInteraction={() => {}}
/>
</SettingsGroup>
</div>
)
}
export const on = defineFixture({ width: 720, render: () => <UsageSettingFixture /> })
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<HTMLButtonElement>('button[aria-label="About Claude usage polling"]')
?.focus()
}
})
@@ -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 (
<SearchableSetting
title={title}
description={description}
keywords={['claude', 'usage', 'oauth', 'credentials', 'rate limit', 'status bar']}
className="flex items-center justify-between gap-4 py-3"
>
<div className="flex min-w-0 items-center gap-1">
<Label htmlFor="claude-oauth-usage" className="min-w-0">
<span className="truncate">{title}</span>
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={t('accounts.claude.oauthUsage.about', 'About Claude usage polling')}
className="shrink-0"
>
<Info className="size-3.5 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-xs">
{description}
</TooltipContent>
</Tooltip>
</div>
<span id="claude-oauth-usage-description" className="sr-only">
{description}
</span>
<Switch
id="claude-oauth-usage"
aria-describedby="claude-oauth-usage-description"
checked={settings.claudeOAuthUsageEnabled !== false}
onCheckedChange={(checked) => {
recordFeatureInteraction('usage-tracking')
updateSettings({ claudeOAuthUsageEnabled: checked })
}}
/>
</SearchableSetting>
)
}
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -21561,6 +21561,11 @@
"reasonSharedSession": "別のペインでも開いています",
"sharedOne": "別のペインで同じ会話が開いているため、1 件の Claude セッションは {{previous}} のままです。再起動すると別のペインの会話が再び開く可能性があります。",
"sharedOther": "別のペインで同じ会話が開いているため、{{count}} 件の Claude セッションは {{previous}} のままです。再起動すると別のペインの会話が再び開く可能性があります。"
},
"oauthUsage": {
"title": "Claude の使用量の定期取得",
"description": "Claude Code のログイン情報を使って、使用量、クレジット、非アクティブなアカウントを確認します。オフにすると、実行中のターミナルセッションからの使用量データのみを表示します。",
"about": "Claude の使用量の定期取得について"
}
},
"hostSignIn": {
+5
View File
@@ -21553,6 +21553,11 @@
"reasonSharedSession": "다른 패널에서도 열려 있음",
"sharedOne": "다른 패널에서 같은 대화를 열고 있어 Claude 세션 1개가 {{previous}}에 유지됩니다. 다시 시작하면 다른 패널의 대화가 다시 열릴 수 있습니다.",
"sharedOther": "다른 패널에서 같은 대화를 열고 있어 Claude 세션 {{count}}개가 {{previous}}에 유지됩니다. 다시 시작하면 다른 패널의 대화가 다시 열릴 수 있습니다."
},
"oauthUsage": {
"title": "Claude 사용량 주기적 조회",
"description": "Claude Code 로그인 정보로 사용량, 크레딧 및 비활성 계정을 확인합니다. 끄면 실행 중인 터미널 세션에서 제공하는 실시간 사용량만 표시합니다.",
"about": "Claude 사용량 주기적 조회 정보"
}
},
"hostSignIn": {
+5
View File
@@ -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": {
+6 -1
View File
@@ -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 используют собственные учётные данные.",
+5
View File
@@ -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": {
@@ -21631,6 +21631,11 @@
"reasonSharedSession": "在另一個窗格中也已開啟",
"sharedOne": "由於另一個窗格開啟了相同對話,1 個 Claude 工作階段會保留在 {{previous}}。重新啟動可能會重新開啟另一個窗格的對話。",
"sharedOther": "由於另一個窗格開啟了相同對話,{{count}} 個 Claude 工作階段會保留在 {{previous}}。重新啟動可能會重新開啟另一個窗格的對話。"
},
"oauthUsage": {
"title": "Claude 用量輪詢",
"description": "使用 Claude Code 登入憑證查詢用量、額度和非使用中帳戶。關閉後,僅顯示執行中終端機工作階段提供的即時用量。",
"about": "關於 Claude 用量輪詢"
}
},
"managedData": {
+5
View File
@@ -21901,6 +21901,11 @@
"reasonSharedSession": "在另一个窗格中也已打开",
"sharedOne": "由于另一个窗格打开了同一对话,1 个 Claude 会话将保持在 {{previous}}。重新启动可能会重新打开另一个窗格的对话。",
"sharedOther": "由于另一个窗格打开了同一对话,{{count}} 个 Claude 会话将保持在 {{previous}}。重新启动可能会重新打开另一个窗格的对话。"
},
"oauthUsage": {
"title": "Claude 用量轮询",
"description": "使用 Claude Code 登录凭据检查用量、额度和非活跃账号。关闭后,仅显示运行中终端会话提供的实时用量。",
"about": "关于 Claude 用量轮询"
}
},
"hostSignIn": {
+1
View File
@@ -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: {},
+2
View File
@@ -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<Record<TuiAgent, string>>
/** Off keeps each Codex home's history to itself: Orca links no sessions between accounts or