@@ -207,15 +207,13 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
+ {quota.status === 'success' && (provider === 'codex' || provider === 'claude') ?
{(quota.creditsUnlimited || quota.creditBalance !== undefined) ? {t('quota.creditBalance')} {quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance} : null}
{t('quota.resetCredits')} {quota.resetCredits ?? '—'}
{quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null}
{t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}
- {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null}
: null}
- {quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ?
{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}
: null}
{quota.status === 'success' ?
{quota.rows.map((row, index) => {
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
return
@@ -224,7 +222,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
{[row.detail, reset].filter(Boolean).join(' · ')}
;
})}
: null}
- {onReset && (quota.resetCredits ?? 0) > 0 ?
: null}
+ {onReset && ((quota.resetCredits ?? 0) > 0 || hasPendingClaudeReset(file)) ?
: null}
);
}
diff --git a/src/services/claudeResetGrants.ts b/src/services/claudeResetGrants.ts
new file mode 100644
index 0000000..367f0b8
--- /dev/null
+++ b/src/services/claudeResetGrants.ts
@@ -0,0 +1,328 @@
+// Upstream cedar_ember contract, following opencodex anthropic-reset-grants.
+import { managementApi, isRecord as isApiRecord } from './managementApi';
+
+const CLAUDE_REQUEST_HEADERS = {
+ 'User-Agent': 'claude-cli/2.1.280 (external, cli)',
+ Authorization: 'Bearer $TOKEN$',
+ 'Content-Type': 'application/json',
+ 'anthropic-beta': 'oauth-2025-04-20',
+};
+const apiCallApi = {
+ async request(body: { authIndex: string; method: string; url: string; header: Record
; data?: string }, options: { timeout: number }) {
+ const response = await managementApi.post>('/requests/api-call', body, { timeoutMs: options.timeout });
+ let payload = response.body ?? response.bodyText;
+ if (typeof payload === 'string') {
+ try { payload = JSON.parse(payload); } catch { payload = null; }
+ }
+ return { statusCode: Number(response.status_code ?? response.statusCode ?? 0), body: isApiRecord(payload) ? payload : null };
+ },
+};
+
+export const ANTHROPIC_API_ORIGIN = 'https://api.anthropic.com';
+export const ANTHROPIC_RESET_GRANT_PROGRAM = 'cedar_ember';
+export const ANTHROPIC_RESET_GRANT_STATUS_PATH = '/api/oauth/usage?cedar_ember=1&skip_spend=1';
+export const ANTHROPIC_PROFILE_PATH = '/api/oauth/profile';
+/** Same bound the Claude Code client uses for the claim. */
+export const ANTHROPIC_RESET_GRANT_REDEEM_TIMEOUT_MS = 25_000;
+
+export const ANTHROPIC_RESET_GRANT_ID_RE = /^[a-z0-9_-]{1,40}$/;
+export const ANTHROPIC_RESET_REQUEST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
+export const ORGANIZATION_UUID_RE =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/** Usage windows a grant can clear. Others upstream may add are dropped. */
+export const ANTHROPIC_RESET_WINDOWS = [
+ 'five_hour',
+ 'seven_day',
+ 'seven_day_overage_included',
+] as const;
+export type AnthropicResetWindow = (typeof ANTHROPIC_RESET_WINDOWS)[number];
+
+/** Terminal answers the claim endpoint can give. */
+export const ANTHROPIC_RESET_RESULTS = [
+ 'reset',
+ 'already_used',
+ 'not_limited',
+ 'cooldown',
+ 'ineligible',
+ 'unavailable',
+] as const;
+export type AnthropicResetUpstreamResult = (typeof ANTHROPIC_RESET_RESULTS)[number];
+/** Upstream results plus the two HTTP refusals that prove nothing was spent. */
+export type AnthropicResetSettledCode =
+ AnthropicResetUpstreamResult | 'rate_limited' | 'auth_error';
+
+export interface AnthropicResetGrant {
+ id: string;
+ label: string;
+ resetsTotal: number;
+ resetsLeft: number;
+ startsAt: string | null;
+ endsAt: string | null;
+ clears: AnthropicResetWindow[];
+ paused: boolean;
+ usableNow: boolean;
+ useRequiresLimit: boolean;
+ percentUsed: Partial>;
+}
+
+export interface AnthropicResetGrantStatus {
+ eligible: boolean;
+ ineligibleReason: string | null;
+ atLimit: boolean;
+ grants: AnthropicResetGrant[];
+ nextGrantId: string | null;
+ weeklyResetsAt: string | null;
+ cooldownUntil: string | null;
+}
+
+export type AnthropicResetGrantErrorCode = 'auth' | 'upstream' | 'malformed';
+
+/** A read failure. `message` is fixed text; upstream detail is never attached. */
+export class AnthropicResetGrantError extends Error {
+ constructor(readonly code: AnthropicResetGrantErrorCode) {
+ super(`Anthropic reset-grant read failed (${code})`);
+ this.name = 'AnthropicResetGrantError';
+ }
+}
+
+/** The claim was sent (or may have been) and no terminal answer came back. */
+export class AnthropicResetGrantUnknownOutcome extends Error {
+ constructor() {
+ super('Anthropic reset-grant claim outcome is unknown');
+ this.name = 'AnthropicResetGrantUnknownOutcome';
+ }
+}
+
+const KNOWN_INELIGIBLE_REASONS = new Set([
+ 'config_off',
+ 'tier',
+ 'seat',
+ 'mobile',
+ 'surface',
+ 'cli_version',
+ 'no_grant',
+ 'tenure',
+ 'other_experiment',
+ 'unavailable',
+ 'unknown',
+]);
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isCount(value: unknown): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
+}
+
+/** Optional ISO timestamp: absent/null → null, anything unparsable rejects the block. */
+function optionalTimestamp(value: unknown): string | null | undefined {
+ if (value === undefined || value === null) return null;
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) return undefined;
+ return value;
+}
+
+function optionalBoolean(value: unknown, fallback: boolean): boolean | undefined {
+ if (value === undefined || value === null) return fallback;
+ return typeof value === 'boolean' ? value : undefined;
+}
+
+/** Upstream display text, stripped of control characters and bounded. */
+function safeLabel(value: unknown): string {
+ if (typeof value !== 'string') return '';
+ return (
+ value
+ // Strip untrusted display control characters.
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 120)
+ );
+}
+
+function parseWindows(value: unknown): AnthropicResetWindow[] | undefined {
+ if (value === undefined || value === null) return [];
+ if (!Array.isArray(value)) return undefined;
+ return ANTHROPIC_RESET_WINDOWS.filter((window) => value.includes(window));
+}
+
+function parsePercentUsed(value: unknown): Partial> {
+ const out: Partial> = {};
+ if (!isRecord(value)) return out;
+ for (const window of ANTHROPIC_RESET_WINDOWS) {
+ const percent = value[window];
+ if (typeof percent === 'number' && Number.isInteger(percent) && percent >= 0 && percent <= 100)
+ out[window] = percent;
+ }
+ return out;
+}
+
+function parseGrant(value: unknown): AnthropicResetGrant | null {
+ if (!isRecord(value)) return null;
+ const { id, resets_total: total, resets_left: left } = value;
+ if (typeof id !== 'string' || !ANTHROPIC_RESET_GRANT_ID_RE.test(id)) return null;
+ if (!isCount(total) || !isCount(left) || left > total) return null;
+ const startsAt = optionalTimestamp(value.starts_at);
+ const endsAt = optionalTimestamp(value.ends_at);
+ const clears = parseWindows(value.clears);
+ const paused = optionalBoolean(value.paused, false);
+ // Missing usability flags default to the refusing side: a grant that does not
+ // say it is usable is not offered for spending.
+ const usableNow = optionalBoolean(value.usable_now, false);
+ const useRequiresLimit = optionalBoolean(value.use_requires_limit, true);
+ if (
+ startsAt === undefined ||
+ endsAt === undefined ||
+ clears === undefined ||
+ paused === undefined ||
+ usableNow === undefined ||
+ useRequiresLimit === undefined
+ )
+ return null;
+ return {
+ id,
+ label: safeLabel(value.label),
+ resetsTotal: total,
+ resetsLeft: left,
+ startsAt,
+ endsAt,
+ clears,
+ paused,
+ usableNow,
+ useRequiresLimit,
+ percentUsed: parsePercentUsed(value.percent_used),
+ };
+}
+
+/**
+ * Parses the `cedar_ember` block. Returns null for a missing or malformed block;
+ * one malformed grant, a duplicate id, or a bad timestamp rejects the whole block.
+ */
+export function parseAnthropicResetGrantStatus(block: unknown): AnthropicResetGrantStatus | null {
+ if (!isRecord(block) || typeof block.eligible !== 'boolean') return null;
+ const rawGrants = block.grants ?? [];
+ if (!Array.isArray(rawGrants)) return null;
+ const grants: AnthropicResetGrant[] = [];
+ const seen = new Set();
+ for (const raw of rawGrants) {
+ const grant = parseGrant(raw);
+ if (!grant || seen.has(grant.id)) return null;
+ seen.add(grant.id);
+ grants.push(grant);
+ }
+ const reason = block.ineligible_reason;
+ if (reason !== undefined && reason !== null && typeof reason !== 'string') return null;
+ const atLimit = optionalBoolean(block.at_limit, false);
+ const weeklyResetsAt = optionalTimestamp(block.weekly_resets_at);
+ const cooldownUntil = optionalTimestamp(block.cooldown_until);
+ if (atLimit === undefined || weeklyResetsAt === undefined || cooldownUntil === undefined)
+ return null;
+ const next = block.next_grant_id;
+ return {
+ eligible: block.eligible,
+ ineligibleReason:
+ typeof reason === 'string'
+ ? KNOWN_INELIGIBLE_REASONS.has(reason)
+ ? reason
+ : 'unknown'
+ : null,
+ atLimit,
+ grants,
+ nextGrantId: typeof next === 'string' && seen.has(next) ? next : null,
+ weeklyResetsAt,
+ cooldownUntil,
+ };
+}
+
+/** Why a grant cannot be spent right now, or null when it can. */
+export function anthropicResetGrantBlocker(
+ status: AnthropicResetGrantStatus,
+ grantId: string
+): 'ineligible' | 'unknown_grant' | 'paused' | 'not_usable' | 'exhausted' | 'not_limited' | null {
+ if (!status.eligible) return 'ineligible';
+ const grant = status.grants.find((candidate) => candidate.id === grantId);
+ if (!grant) return 'unknown_grant';
+ if (grant.paused) return 'paused';
+ if (!grant.usableNow) return 'not_usable';
+ if (grant.resetsLeft <= 0) return 'exhausted';
+ if (grant.useRequiresLimit && !status.atLimit) return 'not_limited';
+ return null;
+}
+
+async function readAccount(authIndex: string, path: string): Promise> {
+ const response = await apiCallApi.request(
+ {
+ authIndex,
+ method: 'GET',
+ url: ANTHROPIC_API_ORIGIN + path,
+ header: { ...CLAUDE_REQUEST_HEADERS },
+ },
+ { timeout: 12000 }
+ );
+ if (response.statusCode < 200 || response.statusCode >= 300 || !isRecord(response.body)) {
+ throw new AnthropicResetGrantError('upstream');
+ }
+ return response.body;
+}
+
+export async function readClaudeResetGrants(authIndex: string) {
+ const body = await readAccount(authIndex, ANTHROPIC_RESET_GRANT_STATUS_PATH);
+ const status = parseAnthropicResetGrantStatus(body.cedar_ember);
+ if (!status) throw new AnthropicResetGrantError('malformed');
+ return status;
+}
+
+export async function readClaudeOrganization(authIndex: string) {
+ const body = await readAccount(authIndex, ANTHROPIC_PROFILE_PATH);
+ const uuid = isRecord(body.organization) ? body.organization.uuid : undefined;
+ if (typeof uuid !== 'string' || !ORGANIZATION_UUID_RE.test(uuid)) {
+ throw new AnthropicResetGrantError('malformed');
+ }
+ return uuid.toLowerCase();
+}
+
+export async function claimClaudeResetGrant(
+ authIndex: string,
+ organization: string,
+ grantId: string,
+ requestId: string
+): Promise {
+ if (
+ !ORGANIZATION_UUID_RE.test(organization) ||
+ !ANTHROPIC_RESET_GRANT_ID_RE.test(grantId) ||
+ !ANTHROPIC_RESET_REQUEST_ID_RE.test(requestId)
+ )
+ throw new AnthropicResetGrantError('malformed');
+ try {
+ const response = await apiCallApi.request(
+ {
+ authIndex,
+ method: 'POST',
+ url: `${ANTHROPIC_API_ORIGIN}/api/organizations/${organization}/reset_rate_limits`,
+ header: { ...CLAUDE_REQUEST_HEADERS },
+ data: JSON.stringify({
+ program: ANTHROPIC_RESET_GRANT_PROGRAM,
+ grant_id: grantId,
+ request_id: requestId,
+ }),
+ },
+ { timeout: ANTHROPIC_RESET_GRANT_REDEEM_TIMEOUT_MS }
+ );
+ if (response.statusCode === 429) return 'rate_limited';
+ if (response.statusCode === 401 || response.statusCode === 403) return 'auth_error';
+ const result = isRecord(response.body) ? response.body.result : undefined;
+ if (
+ response.statusCode >= 200 &&
+ response.statusCode < 300 &&
+ typeof result === 'string' &&
+ (ANTHROPIC_RESET_RESULTS as readonly string[]).includes(result)
+ ) {
+ return result as AnthropicResetUpstreamResult;
+ }
+ } catch {
+ /* The proxy may have sent the claim even if the management request failed. */
+ }
+ throw new AnthropicResetGrantUnknownOutcome();
+}
diff --git a/src/services/claudeResetOperations.ts b/src/services/claudeResetOperations.ts
new file mode 100644
index 0000000..40b6ee5
--- /dev/null
+++ b/src/services/claudeResetOperations.ts
@@ -0,0 +1,95 @@
+import { captureQuotaCacheGeneration } from './quotaCache';
+import {
+ anthropicResetGrantBlocker,
+ claimClaudeResetGrant,
+ readClaudeOrganization,
+ readClaudeResetGrants,
+ type AnthropicResetSettledCode,
+} from './claudeResetGrants';
+
+export const RETRY_WINDOW_MS = 10 * 60 * 1000;
+type Operation = {
+ grantId: string;
+ organization: string;
+ requestId: string;
+ createdAt: number;
+ code?: AnthropicResetSettledCode;
+};
+const defaultDependencies = {
+ revision: captureQuotaCacheGeneration,
+ readStatus: readClaudeResetGrants,
+ readOrganization: readClaudeOrganization,
+ claim: claimClaudeResetGrant,
+ now: () => Date.now(),
+ requestId: () => crypto.randomUUID() as string,
+};
+
+/** Tab-memory journal: survives dialog/card unmounts, rechecks account identity before retries.
+ * No automatic retry. Expired ambiguous operations stay blocked for this app window.
+ * This is not a cross-tab or durable backend spending ledger.
+ */
+export function createResetGrantOperations(deps = defaultDependencies) {
+ // Keep uncertain claims even if list pruning invalidates the quota cache.
+ // Identity is verified again before retrying; never silently start another claim.
+ const operations = new Map();
+ const busy = new Set();
+ const syncSession = () => deps.revision();
+ return {
+ inspect(key: string) {
+ syncSession();
+ return operations.get(key);
+ },
+ async run(key: string, authIndex: string, grantId: string) {
+ const session = syncSession();
+ const assertSession = () => {
+ if (deps.revision() !== session) throw new Error('session');
+ };
+ if (busy.has(key)) throw new Error('busy');
+ busy.add(key);
+ try {
+ let operation = operations.get(key);
+ if (operation?.code) operation = undefined;
+ if (
+ operation &&
+ (operation.grantId !== grantId || deps.now() - operation.createdAt >= RETRY_WINDOW_MS)
+ )
+ throw new Error('expired');
+ const wasRetry = Boolean(operation);
+ const organization = await deps.readOrganization(authIndex);
+ assertSession();
+ if (operation && operation.organization !== organization) throw new Error('identity');
+ if (!operation) {
+ const status = await deps.readStatus(authIndex);
+ assertSession();
+ const grant = status.grants.find((item) => item.id === grantId);
+ const now = deps.now();
+ if (
+ anthropicResetGrantBlocker(status, grantId) ||
+ !grant ||
+ (grant.startsAt && Date.parse(grant.startsAt) > now) ||
+ (grant.endsAt && Date.parse(grant.endsAt) <= now) ||
+ (status.cooldownUntil && Date.parse(status.cooldownUntil) > now)
+ ) {
+ throw new Error('blocked');
+ }
+ operation = { grantId, organization, requestId: deps.requestId(), createdAt: now };
+ operations.set(key, operation);
+ }
+ // Re-check immediately before dispatch; never send against a replacement connection.
+ assertSession();
+ if (wasRetry && deps.now() - operation.createdAt >= RETRY_WINDOW_MS) {
+ throw new Error('expired');
+ }
+ const code = await deps.claim(authIndex, organization, grantId, operation.requestId);
+ assertSession();
+ // A refusal on a retry cannot prove that the earlier ambiguous POST did not spend.
+ if (!wasRetry || code === 'reset' || code === 'already_used') operation.code = code;
+ return { code, unresolved: !operation.code };
+ } finally {
+ busy.delete(key);
+ }
+ },
+ };
+}
+
+export const resetGrantOperations = createResetGrantOperations();
diff --git a/src/services/quotaActions.ts b/src/services/quotaActions.ts
index 1037ba3..166f026 100644
--- a/src/services/quotaActions.ts
+++ b/src/services/quotaActions.ts
@@ -1,12 +1,14 @@
+import { resetGrantOperations, RETRY_WINDOW_MS } from './claudeResetOperations';
+import { selectResetGrant } from './selectClaudeResetGrant';
import {
captureQuotaCacheGeneration,
commitQuotaCacheIfCurrent,
getQuotaCacheSnapshot,
updateQuotaCache,
} from './quotaCache';
-import { readBoolean } from './managementApi';
+import { normalizeAuthIndex, readBoolean } from './managementApi';
import {
- consumeCodexResetCredit, idleQuota, providerForFile, quotaKey,
+ consumeClaudeResetCredit, consumeCodexResetCredit, idleQuota, providerForFile, quotaKey,
type AuthFile, type QuotaState,
} from './quotaService';
@@ -63,3 +65,22 @@ export function resetCodexQuotaWithConfirmation(file: AuthFile, confirmReset: ()
if (!canResetCodexQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeCodexResetCredit);
}
+
+export function hasPendingClaudeReset(file: AuthFile): boolean {
+ const operation = resetGrantOperations.inspect(quotaKey(file));
+ return Boolean(operation && !operation.code);
+}
+
+export function canResetQuota(file: AuthFile, quota: QuotaState): boolean {
+ if (providerForFile(file) !== 'claude') return canResetCodexQuota(file, quota);
+ if (readBoolean(file, 'disabled') || quota.status === 'loading' || !normalizeAuthIndex(file.auth_index ?? file.authIndex)) return false;
+ const operation = resetGrantOperations.inspect(quotaKey(file));
+ if (operation && !operation.code) return Date.now() - operation.createdAt < RETRY_WINDOW_MS;
+ return Boolean(quota.claudeResetGrants && selectResetGrant(quota.claudeResetGrants, Date.now()));
+}
+
+export function resetQuotaWithConfirmation(file: AuthFile, confirmReset: () => Promise): Promise {
+ if (providerForFile(file) !== 'claude') return resetCodexQuotaWithConfirmation(file, confirmReset);
+ if (!canResetQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
+ return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeClaudeResetCredit);
+}
diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts
index eca098f..298efff 100644
--- a/src/services/quotaService.ts
+++ b/src/services/quotaService.ts
@@ -1,3 +1,7 @@
+import { captureQuotaCacheGeneration } from './quotaCache';
+import { readClaudeResetGrants, type AnthropicResetGrantStatus } from './claudeResetGrants';
+import { selectResetGrant } from './selectClaudeResetGrant';
+import { resetGrantOperations, RETRY_WINDOW_MS } from './claudeResetOperations';
import { buildXaiBillingSummary, mergeXaiBillingSummaries, type XaiBillingConfig } from './xaiBilling';
import {
apiCallErrorMessage,
@@ -34,6 +38,7 @@ export type QuotaState = {
plan?: string;
creditBalance?: string;
creditsUnlimited?: boolean;
+ claudeResetGrants?: AnthropicResetGrantStatus;
resetCredits?: number;
resetCreditsApplicable?: number;
resetCreditsError?: string;
@@ -885,10 +890,16 @@ async function loadQuotaSnapshot(file: AuthFile): Promise {
return null;
})
: Promise.resolve(null);
- const [payload, detectedPlan, resetCreditDetails] = await Promise.all([
+ const claudeGrantsPromise = provider === 'claude'
+ ? readClaudeResetGrants(normalizeAuthIndex(file.auth_index ?? file.authIndex)).catch(() => {
+ resetCreditsError = quotaText('quota.claude.readError');
+ return undefined;
+ }) : Promise.resolve(undefined);
+ const [payload, detectedPlan, resetCreditDetails, claudeResetGrants] = await Promise.all([
payloadPromise,
planPromise,
resetCreditsPromise,
+ claudeGrantsPromise,
]);
const rows = quotaRowsFor(provider, payload);
if (rows.length === 0) {
@@ -900,7 +911,7 @@ async function loadQuotaSnapshot(file: AuthFile): Promise {
}
const resetCredits = provider === 'codex'
? resetCreditDetails?.availableCount ?? codexResetCreditsFor(payload)
- : undefined;
+ : claudeResetGrants?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0);
const usageCreditDetails = provider === 'codex' && isRecord(payload)
? codexResetCreditDetailsFor(payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits)
: {};
@@ -914,11 +925,12 @@ async function loadQuotaSnapshot(file: AuthFile): Promise {
creditsUnlimited: accountCredits.unlimited,
subscriptionActiveUntil: provider === 'devin'
? readDevinQuota(payload).subscriptionActiveUntil : codexMetadata?.subscriptionActiveUntil,
+ claudeResetGrants,
resetCreditsError,
- resetCreditsApplicable: usageCreditDetails.applicableAvailableCount
+ resetCreditsApplicable: provider === 'claude' ? undefined : usageCreditDetails.applicableAvailableCount
?? resetCreditDetails?.applicableAvailableCount ?? resetCredits,
resetCredits,
- resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry,
+ resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry ?? claudeResetGrants?.grants.filter(grant => grant.resetsLeft > 0 && grant.endsAt).map(grant => grant.endsAt!).sort((a, b) => Date.parse(a) - Date.parse(b))[0],
serverTimeOffsetMs: responseClock.serverTimeOffsetMs,
fetchedAt: Date.now(),
};
@@ -997,3 +1009,30 @@ function runQuotaMutation(file: AuthFile, mutate: () => Promise): Pr
export function consumeCodexResetCredit(file: AuthFile): Promise {
return runQuotaMutation(file, () => consumeCodexResetCreditSnapshot(file));
}
+
+export function consumeClaudeResetCredit(file: AuthFile): Promise {
+ const generation = captureQuotaCacheGeneration();
+ return runQuotaMutation(file, async () => {
+ if (captureQuotaCacheGeneration() !== generation) throw new Error(quotaText('quota.claude.blocked'));
+ if (providerForFile(file) !== 'claude' || booleanValue(file.disabled) === true)
+ throw new Error(quotaText('quota.claude.blocked'));
+ const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
+ if (!authIndex) throw new Error(quotaText('quota.claude.blocked'));
+ const key = quotaKey(file);
+ const pending = resetGrantOperations.inspect(key);
+ const grantId = pending && !pending.code ? pending.grantId
+ : selectResetGrant(await readClaudeResetGrants(authIndex), Date.now())?.id;
+ if (!grantId || captureQuotaCacheGeneration() !== generation) throw new Error(quotaText('quota.claude.blocked'));
+ try {
+ const answer = await resetGrantOperations.run(key, authIndex, grantId);
+ if (answer.unresolved) throw new Error(quotaText('quota.claude.unknown'));
+ if (answer.code !== 'reset' && answer.code !== 'already_used')
+ throw new Error(quotaText(`quota.claude.${answer.code}`));
+ } catch (error) {
+ const operation = resetGrantOperations.inspect(key);
+ if (operation && !operation.code) throw new Error(quotaText(Date.now() - operation.createdAt >= RETRY_WINDOW_MS ? 'quota.claude.expired' : 'quota.claude.unknown'));
+ throw error;
+ }
+ return loadQuotaSnapshot(file);
+ });
+}
diff --git a/src/services/selectClaudeResetGrant.ts b/src/services/selectClaudeResetGrant.ts
new file mode 100644
index 0000000..4793cac
--- /dev/null
+++ b/src/services/selectClaudeResetGrant.ts
@@ -0,0 +1,19 @@
+import {
+ anthropicResetGrantBlocker,
+ type AnthropicResetGrantStatus,
+} from './claudeResetGrants';
+
+/** Prefer the upstream recommendation; otherwise use stable ID ordering. */
+export function selectResetGrant(status: AnthropicResetGrantStatus, now: number) {
+ if (status.cooldownUntil && Date.parse(status.cooldownUntil) > now) return undefined;
+ const usable = status.grants.filter(
+ (grant) =>
+ !anthropicResetGrantBlocker(status, grant.id) &&
+ (!grant.startsAt || Date.parse(grant.startsAt) <= now) &&
+ (!grant.endsAt || Date.parse(grant.endsAt) > now)
+ );
+ return (
+ usable.find((grant) => grant.id === status.nextGrantId) ??
+ usable.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]
+ );
+}
diff --git a/tests/authFileQuotaDense.test.tsx b/tests/authFileQuotaDense.test.tsx
index fedf551..c3b99cb 100644
--- a/tests/authFileQuotaDense.test.tsx
+++ b/tests/authFileQuotaDense.test.tsx
@@ -24,12 +24,13 @@ it('keeps window percentages, reset times and additional windows available in de
expect(html).toContain('extra');
});
-it('shows reset credits in the button while preserving applicability and expiry', () => {
+it('shows reset credits beside the button while preserving applicability and expiry', () => {
const html = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 0 }],
resetCredits: 2, resetCreditsApplicable: 0, subscriptionActiveUntil: '2030-01-01T00:00:00Z',
});
const resetButton = html.match(/