diff --git a/package-lock.json b/package-lock.json index 7b73efd..9c6e812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.38.0", + "version": "1.38.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.38.0", + "version": "1.38.1", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 700178c..782ff13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.38.0", + "version": "1.38.1", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", diff --git a/src/main/claudeUsage.test.ts b/src/main/claudeUsage.test.ts index 6c85db8..b077f42 100644 --- a/src/main/claudeUsage.test.ts +++ b/src/main/claudeUsage.test.ts @@ -57,6 +57,20 @@ describe('getClaudeUsage', () => { expect((await getClaudeUsage(deps({ readCredentials: () => ({ ...baseCreds, expiresAt: 500 }) }))).state).toBe('expired'); }); + it('expired access token with a live refresh token => refresh-pending, not a re-login', async () => { + let fetched = false; + const fetchUsage = async () => { fetched = true; return { ok: false, status: 401 }; }; + const pending = await getClaudeUsage(deps({ fetchUsage, readCredentials: () => ({ ...baseCreds, expiresAt: 500, hasRefreshToken: true, refreshTokenExpiresAt: 5000 }) })); + expect(pending.state).toBe('refresh-pending'); + expect(fetched).toBe(false); + expect((await getClaudeUsage(deps({ readCredentials: () => ({ ...baseCreds, expiresAt: 500, hasRefreshToken: true, refreshTokenExpiresAt: null }) }))).state).toBe('refresh-pending'); + }); + + it('expired access token whose refresh token is gone or expired => expired', async () => { + expect((await getClaudeUsage(deps({ readCredentials: () => ({ ...baseCreds, expiresAt: 500, hasRefreshToken: true, refreshTokenExpiresAt: 900 }) }))).state).toBe('expired'); + expect((await getClaudeUsage(deps({ readCredentials: () => ({ ...baseCreds, expiresAt: 500, hasRefreshToken: false }) }))).state).toBe('expired'); + }); + it('api subscription => not-applicable', async () => { expect((await getClaudeUsage(deps({ readCredentials: () => ({ ...baseCreds, subscriptionType: 'api' }) }))).state).toBe('not-applicable'); }); diff --git a/src/main/claudeUsage.ts b/src/main/claudeUsage.ts index d1d633b..09b0997 100644 --- a/src/main/claudeUsage.ts +++ b/src/main/claudeUsage.ts @@ -8,7 +8,11 @@ import { parseClaudeUsageResponse, type ProviderUsage } from '../shared/usageWin const API_TIMEOUT_MS = 15_000; const MAX_RESPONSE_BYTES = 256 * 1024; -export interface Credentials { accessToken: string; subscriptionType: string; expiresAt: number | null; } +export interface Credentials { + accessToken: string; subscriptionType: string; expiresAt: number | null; + /** Whether a refresh token is on disk, and when it stops working. The token itself is never read out. */ + hasRefreshToken?: boolean; refreshTokenExpiresAt?: number | null; +} export interface FetchResult { ok: boolean; body?: unknown; status?: number; } export interface UsageDeps { @@ -57,7 +61,16 @@ export async function getClaudeUsage(deps: UsageDeps): Promise { const creds = deps.readCredentials(); if (!creds) return result('login-required', now); - if (creds.expiresAt != null && creds.expiresAt <= now) return result('expired', now); + if (creds.expiresAt != null && creds.expiresAt <= now) { + // The access token lives ~8 hours and Claude Code renews it only when it next calls the API, so an + // idle night leaves an expired one on disk every morning. That is not a lost sign-in: while the + // refresh token is still good, the next reply renews the file and the following poll recovers. + // Calling it "expired" sent the user through /login daily for nothing. DevDeck must not refresh it + // itself — refresh tokens rotate, and the running Claude Code sessions would be left holding a dead one. + const refreshable = creds.hasRefreshToken === true + && (creds.refreshTokenExpiresAt == null || creds.refreshTokenExpiresAt > now); + return result(refreshable ? 'refresh-pending' : 'expired', now); + } const plan = planName(creds.subscriptionType); if (!plan) return result('not-applicable', now); // API / custom-key mode has no subscription windows @@ -84,6 +97,8 @@ export function readClaudeCredentials(home = homedir()): Credentials | null { accessToken, subscriptionType: typeof o.subscriptionType === 'string' ? o.subscriptionType : '', expiresAt: typeof o.expiresAt === 'number' ? o.expiresAt : null, + hasRefreshToken: typeof o.refreshToken === 'string' && o.refreshToken.length > 0, + refreshTokenExpiresAt: typeof o.refreshTokenExpiresAt === 'number' ? o.refreshTokenExpiresAt : null, }; } catch { return null; } } diff --git a/src/main/usageProviders.ts b/src/main/usageProviders.ts index ddee896..b771452 100644 --- a/src/main/usageProviders.ts +++ b/src/main/usageProviders.ts @@ -18,7 +18,7 @@ export function antigravityUsage(now: number): ProviderUsage { }; } -const STATES: UsageProviderState[] = ['ready', 'stale', 'login-required', 'expired', 'not-applicable', 'cli-missing', 'offline', 'rate-limited', 'unsupported']; +const STATES: UsageProviderState[] = ['ready', 'stale', 'login-required', 'expired', 'not-applicable', 'cli-missing', 'offline', 'rate-limited', 'unsupported', 'refresh-pending']; const KINDS = ['session', 'weekly', 'model-weekly', 'primary', 'secondary']; function sanitizeLimit(v: unknown): UsageLimit | null { @@ -65,7 +65,7 @@ export interface UsageCoordinatorDeps { } /** States that carry no numbers of their own — for these, last-good data is worth keeping as `stale`. */ -const DATALESS = new Set(['offline', 'rate-limited', 'expired']); +const DATALESS = new Set(['offline', 'rate-limited', 'expired', 'refresh-pending']); /** * Ceiling on one provider read, above every provider's own timeouts. diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index dee3acb..368317c 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -326,6 +326,8 @@ "usage.state_offline": "Unavailable", "usage.state_rate_limited": "API busy", "usage.state_unsupported": "Check in CLI", + "usage.state_refresh_pending": "Awaiting refresh", + "usage.refresh_pending_hint": "Sign-in renews automatically the next time Claude Code responds — no need to log in again", "usage.local_title": "Claude Code + Codex local analytics", "usage.local_explainer": "API-equivalent token and cost history computed from ~/.claude and ~/.codex logs. It may differ from subscription billing. Antigravity is excluded because stable local cost data is unavailable; the footer usage bar continues to show live subscription limits.", "usage.range_label": "Analysis period", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index e3c7833..ced6e3c 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -326,6 +326,8 @@ "usage.state_offline": "取得できません", "usage.state_rate_limited": "API混雑", "usage.state_unsupported": "CLIで確認", + "usage.state_refresh_pending": "自動更新待ち", + "usage.refresh_pending_hint": "次に Claude Code が応答すると自動で更新されます — 再ログインは不要です", "usage.local_title": "Claude Code + Codex ローカル分析", "usage.local_explainer": "~/.claude と ~/.codex のログから算出した API 換算のトークン・コスト履歴です。実際のサブスクリプション請求とは異なる場合があります。安定したローカルコストデータがない Antigravity は対象外で、フッターの使用量バーは従来どおりリアルタイム上限を表示します。", "usage.range_label": "分析期間", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 788660b..57707e2 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -326,6 +326,8 @@ "usage.state_offline": "연결 실패", "usage.state_rate_limited": "API 혼잡", "usage.state_unsupported": "CLI에서 확인", + "usage.state_refresh_pending": "자동 갱신 대기", + "usage.refresh_pending_hint": "Claude Code가 다음에 응답할 때 로그인이 자동으로 갱신됩니다 — 다시 로그인할 필요 없음", "usage.local_title": "Claude Code + Codex 로컬 분석", "usage.local_explainer": "~/.claude 및 ~/.codex 로그에서 계산한 API 환산 토큰·비용 기록입니다. 실제 구독 청구액과 다를 수 있으며, 안정적인 로컬 비용 데이터가 없는 Antigravity는 포함하지 않습니다. 하단 사용량 바는 기존처럼 실시간 구독 한도를 표시합니다.", "usage.range_label": "분석 기간", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 1de5467..7306a5f 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -326,6 +326,8 @@ "usage.state_offline": "无法获取", "usage.state_rate_limited": "API 繁忙", "usage.state_unsupported": "在 CLI 查看", + "usage.state_refresh_pending": "等待自动刷新", + "usage.refresh_pending_hint": "下次 Claude Code 响应时登录会自动续期 — 无需重新登录", "usage.local_title": "Claude Code + Codex 本地分析", "usage.local_explainer": "根据 ~/.claude 和 ~/.codex 日志计算的 API 等效 token 与费用记录,可能与实际订阅账单不同。由于缺少稳定的本地费用数据,暂不包含 Antigravity;底栏用量条仍按原方式显示实时订阅额度。", "usage.range_label": "分析期间", diff --git a/src/renderer/usageBar.ts b/src/renderer/usageBar.ts index 717e09c..1adc7dc 100644 --- a/src/renderer/usageBar.ts +++ b/src/renderer/usageBar.ts @@ -99,11 +99,14 @@ function startTimer(): void { if (!timer) timer = setInterval(() => { void refre function staleChip(summary: ReturnType, now: number): HTMLElement { const fixable = summary.staleReason === 'expired' || summary.staleReason === 'login-required'; const age = summary.staleSince != null ? formatStaleAge(summary.staleSince, now, tr) : null; - const cause = fixable ? tr(usageStateKey(summary.staleReason!)) : tr('usage.state_stale'); + // An access token waiting for Claude Code's next reply is named too, but as the self-healing pause it + // is — no button, since signing in again would fix nothing. + const pending = summary.staleReason === 'refresh-pending'; + const cause = fixable || pending ? tr(usageStateKey(summary.staleReason!)) : tr('usage.state_stale'); const text = age ? `${cause} · ${age}` : cause; if (!fixable) { const st = document.createElement('span'); st.className = 'ub-stale'; st.textContent = text; - st.title = `${tr('usage.stale_note')}${age ? ` · ${age}` : ''}`; + st.title = pending ? tr('usage.refresh_pending_hint') : `${tr('usage.stale_note')}${age ? ` · ${age}` : ''}`; return st; } const st = document.createElement('button'); st.type = 'button'; st.className = 'ub-stale fix'; st.textContent = text; diff --git a/src/renderer/usageModal.ts b/src/renderer/usageModal.ts index d4955e2..386548d 100644 --- a/src/renderer/usageModal.ts +++ b/src/renderer/usageModal.ts @@ -107,6 +107,7 @@ function providerSection(p: ProviderUsage): HTMLElement { if (p.staleReason) stateParts.push(tr(usageStateKey(p.staleReason))); } state.textContent = stateParts.join(' · '); + if ((p.state === 'stale' ? p.staleReason : p.state) === 'refresh-pending') state.title = tr('usage.refresh_pending_hint'); head.appendChild(state); sec.appendChild(head); diff --git a/src/shared/usageWindows.test.ts b/src/shared/usageWindows.test.ts index 6857914..b6f14a9 100644 --- a/src/shared/usageWindows.test.ts +++ b/src/shared/usageWindows.test.ts @@ -65,7 +65,7 @@ describe('formatReset', () => { describe('usageStateKey', () => { it('maps every normalized state to an i18n key', () => { - for (const s of ['ready', 'stale', 'login-required', 'expired', 'not-applicable', 'cli-missing', 'offline', 'rate-limited', 'unsupported'] as const) { + for (const s of ['ready', 'stale', 'login-required', 'expired', 'not-applicable', 'cli-missing', 'offline', 'rate-limited', 'unsupported', 'refresh-pending'] as const) { expect(usageStateKey(s)).toMatch(/^usage\.state_/); } }); @@ -77,6 +77,8 @@ describe('usageActionFor', () => { expect(usageActionFor('claude', 'expired')).toBe('login'); expect(usageActionFor('codex', 'cli-missing')).toBe('install'); expect(usageActionFor('claude', 'ready')).toBeNull(); + expect(usageActionFor('claude', 'refresh-pending')).toBeNull(); + expect(usageActionFor('claude', 'stale', 'refresh-pending')).toBeNull(); expect(usageActionFor('antigravity', 'unsupported')).toBeNull(); }); diff --git a/src/shared/usageWindows.ts b/src/shared/usageWindows.ts index 8100f83..a9d7e62 100644 --- a/src/shared/usageWindows.ts +++ b/src/shared/usageWindows.ts @@ -8,7 +8,7 @@ export type Severity = 'ok' | 'warn' | 'crit'; /** Why a provider section looks the way it does. `stale` = last-good data after a failed refresh. */ export type UsageProviderState = | 'ready' | 'stale' | 'login-required' | 'expired' | 'not-applicable' - | 'cli-missing' | 'offline' | 'rate-limited' | 'unsupported'; + | 'cli-missing' | 'offline' | 'rate-limited' | 'unsupported' | 'refresh-pending'; /** `session`/`weekly` = Claude's fixed windows, `model-weekly` = a model-scoped weekly quota, * `primary`/`secondary` = Codex's two app-server windows. */