Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/main/claudeUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
19 changes: 17 additions & 2 deletions src/main/claudeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,7 +61,16 @@ export async function getClaudeUsage(deps: UsageDeps): Promise<ProviderUsage> {

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
Expand All @@ -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; }
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UsageProviderState>(['offline', 'rate-limited', 'expired']);
const DATALESS = new Set<UsageProviderState>(['offline', 'rate-limited', 'expired', 'refresh-pending']);

/**
* Ceiling on one provider read, above every provider's own timeouts.
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "分析期間",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "분석 기간",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "分析期间",
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/usageBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,14 @@ function startTimer(): void { if (!timer) timer = setInterval(() => { void refre
function staleChip(summary: ReturnType<typeof summarizeProviderUsage>, 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;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/usageModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion src/shared/usageWindows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_/);
}
});
Expand All @@ -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();
});

Expand Down
2 changes: 1 addition & 1 deletion src/shared/usageWindows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading