From f5c2e10ae8b52d3eede05c58d7ef09cbc7f0bdb0 Mon Sep 17 00:00:00 2001 From: e7d Date: Sat, 1 Aug 2026 15:12:44 +0000 Subject: [PATCH 1/4] fix: track Claude Opus 5 as a 1M-context model (#81) Claude Code writes the plain `claude-opus-5` id to its JSONL even when the session runs the 1M-context variant, so the existing `[1m]` marker never fired for it. `contextWindowFor` fell through to the generic opus/sonnet branch and returned 200K flagged as exact, making the reported context percentage five times too high with no `estimated` hint to soften it. The major-version branch now covers Opus 5+ as well as Sonnet 5+. Pre-4.6 Opus/Sonnet and dated 3.x snapshots keep their 200K window (the alternation still cannot match a date suffix such as `sonnet-20241022`). `claude-opus-5` also gets an explicit pricing entry. The family fallback already resolved it to the correct $5/$25 current-Opus tier, so no cost figure changes; the entry mirrors the existing `claude-sonnet-5` anchor and drops the spurious `Unknown model` line from the diagnostic output. Opus 5 pricing and context window verified 2026-07-28 against https://platform.claude.com/docs/en/about-claude/models/overview --- CHANGELOG.md | 9 +++++++++ src/dataLoader.ts | 14 +++++++------- src/pricing.ts | 3 +++ src/test/dataLoader.test.ts | 17 +++++++++++++++++ src/test/pricing.test.ts | 9 +++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c00545..212a6885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel are credited as development tools, separately from human contributors. ### Fixed +- **Opus 5 context window** — `claude-opus-5` reported a 200 000-token window + as if it were exact, so the status bar and dashboard showed a context + percentage five times too high. Claude Code writes the plain `claude-opus-5` + id to its JSONL even for the 1M-context variant, so the existing `[1m]` + marker never covered it. The major-version check now recognises Opus 5+ + alongside Sonnet 5+, and `claude-opus-5` is an explicit entry in the pricing + table (same $5 / $25 current-Opus tier the family fallback already resolved, + minus the spurious `Unknown model` diagnostic). + - **High-CPU refresh mitigation (#70)** — polling now always honors the configured 30–3600 second `refreshInterval`; file watching is quiet-debounce only and adds 60/120/300-second choices. First-timestamp reads stop after the diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 23698d7e..e8474586 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -1194,15 +1194,16 @@ export class ClaudeDataLoader { } /** Model context-window size in tokens, plus whether it's a guess. Current - * Claude (Opus 4.6+, Sonnet 4.6+, Sonnet 5+, Fable/Mythos 5) is 1M; Haiku - * and older Claude are 200K; a "[1m]" suffix forces 1M (the marker + * Claude (Opus 4.6+, Opus 5+, Sonnet 4.6+, Sonnet 5+, Fable/Mythos 5) is 1M; + * Haiku and older Claude are 200K; a "[1m]" suffix forces 1M (the marker * pricing.ts strips). A user override (>0) wins outright and is treated as * exact. Unrecognised / proxied models fall back to 200K and are flagged * `estimated` so the UI can mark the percentage as approximate. * Sonnet 5 (`claude-sonnet-5`) verified 2026-07-01 — * https://platform.claude.com/docs/en/about-claude/models/whats-new-sonnet-5 * ("1M tokens is both the default and the maximum; there is no smaller - * context variant"). */ + * context variant"). Opus 5 (`claude-opus-5`) verified 2026-07-28 — + * https://platform.claude.com/docs/en/about-claude/models/overview */ private static contextWindowFor( model: string, override: number = 0 @@ -1221,10 +1222,9 @@ export class ClaudeDataLoader { if (/opus-4-(?:[6-9]|\d\d)\b/.test(m) || /sonnet-4-(?:[6-9]|\d\d)\b/.test(m)) { return { tokens: 1_000_000, estimated: false }; } - // Sonnet 5.x+ (e.g. "claude-sonnet-5") has no "-4-" segment to match - // above, so check the major version directly. (Opus 5 doesn't exist yet — - // don't guess its window size here.) - if (/sonnet-(?:[5-9]|\d\d)(?:-|\b)/.test(m)) { + // Opus 5.x+ / Sonnet 5.x+ (e.g. "claude-opus-5") have no "-4-" segment to + // match above, so check the major version directly. + if (/(?:opus|sonnet)-(?:[5-9]|\d\d)(?:-|\b)/.test(m)) { return { tokens: 1_000_000, estimated: false }; } if (/haiku/.test(m) || /opus|sonnet/.test(m)) { diff --git a/src/pricing.ts b/src/pricing.ts index 43e4d7d4..8e37ba2a 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -231,6 +231,9 @@ const MODEL_PRICING: Record = { 'claude-fable-5': FABLE_5, 'claude-mythos-5': FABLE_5, + // Claude Opus 5 — same current-Opus tier rate, verified 2026-07-28. + 'claude-opus-5': OPUS_CURRENT, + // Claude Opus 4.8 / 4.7 / 4.6 — current Opus tier rate, verified 2026-06-09 // against the official pricing page ($5 / $25 / $6.25 / $0.5 per MTok). 'claude-opus-4-8': OPUS_CURRENT, diff --git a/src/test/dataLoader.test.ts b/src/test/dataLoader.test.ts index 697c5bf2..c8a22f4a 100644 --- a/src/test/dataLoader.test.ts +++ b/src/test/dataLoader.test.ts @@ -31,6 +31,23 @@ test('getCurrentContextInfo reports a 1M window for Sonnet 5', () => { assert.equal(info!.estimated, false); }); +test('getCurrentContextInfo reports a 1M window for Opus 5, with or without the [1m] marker', () => { + for (const model of ['claude-opus-5', 'claude-opus-5[1m]']) { + const info = ClaudeDataLoader.getCurrentContextInfo([record(model)]); + assert.ok(info, `expected context info for ${model}, got null`); + assert.equal(info!.windowTokens, 1_000_000, model); + assert.equal(info!.estimated, false, model); + } +}); + +test('getCurrentContextInfo keeps the 200K window for pre-4.6 Opus and Sonnet', () => { + for (const model of ['claude-opus-4-20250514', 'claude-sonnet-4-5-20250929', 'claude-3-5-sonnet-20241022']) { + const info = ClaudeDataLoader.getCurrentContextInfo([record(model)]); + assert.ok(info, `expected context info for ${model}, got null`); + assert.equal(info!.windowTokens, 200_000, model); + } +}); + test('an injected manifest is authoritative and returns anonymous load counters', async () => { const manifestRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-manifest-')); const emptyArgumentRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-empty-')); diff --git a/src/test/pricing.test.ts b/src/test/pricing.test.ts index 4195896d..f92e6b52 100644 --- a/src/test/pricing.test.ts +++ b/src/test/pricing.test.ts @@ -67,6 +67,15 @@ test('cache-write pricing falls back to the 5-minute rate when no TTL split is p assert.ok(Math.abs(breakdown.cacheWrite - 6.25) < 1e-9, `expected ~6.25, got ${breakdown.cacheWrite}`); }); +test('getModelPricing resolves Opus 5 from the exact table, long-context variant included', () => { + for (const model of ['claude-opus-5', 'claude-opus-5[1m]']) { + const pricing = getModelPricing(model); + assert.ok(pricing, `expected pricing for ${model}, got null`); + assert.equal(pricing!.input_cost_per_token, 5 / 1_000_000, model); + assert.equal(pricing!.output_cost_per_token, 25 / 1_000_000, model); + } +}); + test('getModelPricing falls back to the right family for an unknown snapshot', () => { // An unreleased Opus snapshot isn't in the exact table; it should still // resolve to the current Opus tier ($5/MTok input) rather than a wrong rate. From 231a30b76cb53b607c535ed3b3577c03560306d2 Mon Sep 17 00:00:00 2001 From: ptweezy Date: Sat, 1 Aug 2026 11:19:06 -0400 Subject: [PATCH 2/4] fix: one unprobeable JSONL no longer blanks usage (#79) sortUsageFilesByEarliestTimestamp mapped readEarliestTimestamp over every file with no per-file error handling. A file that trips the probe's 1 MiB no-timestamp guard (TimestampProbeLimitError), such as a large workflow journal.jsonl, rejected the whole batch, so loadUsageRecords returned empty records and the dashboard blanked on every refresh (failed=1, bytes=0). Catch the per-file failure and fall back to timestampMs 0 so the file sorts by discovery order, matching the existing small-no-timestamp behavior. The rest of the load proceeds and the file still reaches the main reader. readEarliestTimestamp and its test are unchanged. Adds a regression test. Co-authored-by: Carl <74130028+Carl723000@users.noreply.github.com> Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 +++++++ src/claudeUsageFiles.ts | 28 +++++++++++++++++++----- src/test/claudeUsageFiles.test.ts | 36 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 212a6885..7181c9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel are credited as development tools, separately from human contributors. ### Fixed +- **Usage dashboard no longer blanks on one oversized non-transcript file** — a + single `.jsonl` with more than 1 MiB of leading lines that carry no + `timestamp` (such as a large workflow `journal.jsonl`, whose records hold + `type`/`key`/`result`/`agentId` and never a timestamp) made the + earliest-timestamp probe throw `TimestampProbeLimitError`. That rejection was + unhandled, so it aborted the whole load and left the dashboard empty on every + refresh (`failed=1`, `bytes=0` in diagnostics). The probe now fails soft for + that one file and the rest load normally. - **Opus 5 context window** — `claude-opus-5` reported a 200 000-token window as if it were exact, so the status bar and dashboard showed a context percentage five times too high. Claude Code writes the plain `claude-opus-5` diff --git a/src/claudeUsageFiles.ts b/src/claudeUsageFiles.ts index 0d381956..0db5792e 100644 --- a/src/claudeUsageFiles.ts +++ b/src/claudeUsageFiles.ts @@ -204,11 +204,29 @@ export async function readEarliestTimestamp( export async function sortUsageFilesByEarliestTimestamp( entries: readonly UsageFileFingerprint[], ): Promise<{ files: string[]; bytesRead: number }> { - const stamped = await mapWithConcurrency(entries, 8, async (entry) => ({ - file: entry.path, - discoveryIndex: entry.discoveryIndex, - ...(await readEarliestTimestamp(entry.path)), - })); + const stamped = await mapWithConcurrency(entries, 8, async (entry) => { + try { + return { + file: entry.path, + discoveryIndex: entry.discoveryIndex, + ...(await readEarliestTimestamp(entry.path)), + }; + } catch { + // A single file we cannot probe must not abort the whole load. This + // happens when a .jsonl has more than 1 MiB of leading lines with no + // `timestamp` (readEarliestTimestamp throws TimestampProbeLimitError) — + // e.g. a large workflow `journal.jsonl`, whose records carry no + // timestamp — or on a transient read error. Fall back to a neutral stamp + // so the file sorts by discovery order, exactly as a small no-timestamp + // file already does, and the main reader still processes it. + return { + file: entry.path, + discoveryIndex: entry.discoveryIndex, + timestampMs: 0, + bytesRead: 0, + }; + } + }); stamped.sort((a, b) => a.timestampMs - b.timestampMs || a.discoveryIndex - b.discoveryIndex, ); diff --git a/src/test/claudeUsageFiles.test.ts b/src/test/claudeUsageFiles.test.ts index b15ae3f6..840370fd 100644 --- a/src/test/claudeUsageFiles.test.ts +++ b/src/test/claudeUsageFiles.test.ts @@ -191,3 +191,39 @@ test('equal or invalid timestamps preserve v2.2.0 discovery order', async () => ]); assert.deepEqual(invalidPair.files, [invalid, invalid2]); }); + +test('a single unprobeable file does not abort ordering the rest', async () => { + const { root } = await fixture(); + const project = path.join(root, 'projects', '-tmp-demo'); + const good = path.join(project, 'good.jsonl'); + const journal = path.join(project, 'journal.jsonl'); + await writeFile(good, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + // A large workflow journal.jsonl: >1 MiB of records that never carry a + // `timestamp`, which makes readEarliestTimestamp throw TimestampProbeLimitError. + await writeFile(journal, '{"type":"result","key":"k","agentId":"a"}\n'.repeat(40_000)); + await assert.rejects( + () => readEarliestTimestamp(journal), + { name: 'TimestampProbeLimitError' }, + ); + + const make = async (file: string, discoveryIndex: number) => { + const fileStat = await stat(file); + return { + path: file, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + discoveryIndex, + dev: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.dev : undefined, + ino: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.ino : undefined, + }; + }; + + // Before the fix this rejected — one unprobeable file zeroed the whole load. + // Now it resolves and keeps every file, the unprobeable one stamped 0 so it + // sorts ahead of the real timestamp by discovery order. + const sorted = await sortUsageFilesByEarliestTimestamp([ + await make(journal, 0), + await make(good, 1), + ]); + assert.deepEqual(sorted.files, [journal, good]); +}); From 37d3255be9d93f525e305a79a25afae02ee0e2ab Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:21:27 +0100 Subject: [PATCH 3/4] fix: reduce background refresh energy use for v2.2.2 (#83) Suspend polling and file watchers in unfocused VS Code windows, refresh on focus recovery, and back off repeated quota authentication failures.\n\nCo-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- .github/scripts/automationPolicy.test.mjs | 4 +- CHANGELOG.md | 12 +- ...-v2.2.2-background-refresh-energy.zh-CN.md | 397 ++++++++++++++++++ ...-background-refresh-energy-design.zh-CN.md | 109 +++++ src/extension.ts | 98 +++-- src/refreshPolicy.ts | 22 + src/test/extensionWindowActivity.test.ts | 185 ++++++++ src/test/refreshPolicy.test.ts | 18 + src/test/repositoryPolicy.test.ts | 7 +- 9 files changed, 818 insertions(+), 34 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-30-v2.2.2-background-refresh-energy.zh-CN.md create mode 100644 docs/superpowers/specs/2026-07-30-v2.2.2-background-refresh-energy-design.zh-CN.md create mode 100644 src/test/extensionWindowActivity.test.ts diff --git a/.github/scripts/automationPolicy.test.mjs b/.github/scripts/automationPolicy.test.mjs index 21a09059..fce6d3a0 100644 --- a/.github/scripts/automationPolicy.test.mjs +++ b/.github/scripts/automationPolicy.test.mjs @@ -92,9 +92,9 @@ test('CONTRIBUTING distinguishes current automatic, reviewed Codex, and privileg assert.match(contributing, /does not migrate this privileged workflow to Codex/); }); -test('automation hardening is appended to the one existing v2.2.1 section', () => { +test('released v2.2.1 retains its automation hardening notes in one section', () => { const changelog = read('CHANGELOG.md'); - assert.equal((changelog.match(/^## \[2\.2\.1\] — Unreleased$/gm) ?? []).length, 1); + assert.equal((changelog.match(/^## \[2\.2\.1\] — 2026-07-18$/gm) ?? []).length, 1); assert.match(changelog, /truthful per-tier provider attribution/); assert.match(changelog, /bounded base-repository file reads/); }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 7181c9cd..a2c99673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,17 @@ All notable changes to this fork compared to upstream [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) (last upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangelog.com). -## [2.2.1] — Unreleased +## [2.2.2] — Unreleased + +### Fixed +- **Lower multi-window energy use** — Suspend polling and file watchers in + unfocused VS Code windows, then refresh immediately when the window regains + focus. This avoids repeating the same local scan in every Extension Host. +- **Quota failure throttling** — Back off repeated quota authentication failures + for up to one hour, while retrying immediately after Claude credentials + change. + +## [2.2.1] — 2026-07-18 ### Added - **Bahasa Indonesia (`id`) (#76)** — the extension's eighth UI language covers diff --git a/docs/superpowers/plans/2026-07-30-v2.2.2-background-refresh-energy.zh-CN.md b/docs/superpowers/plans/2026-07-30-v2.2.2-background-refresh-energy.zh-CN.md new file mode 100644 index 00000000..95f9077f --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-v2.2.2-background-refresh-energy.zh-CN.md @@ -0,0 +1,397 @@ +# V2.2.2 后台刷新能耗修复实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让只有前台 VS Code 窗口执行周期刷新与 watcher,并降低连续 OAuth 失败带来的周期网络唤醒。 + +**Architecture:** 在 `refreshPolicy.ts` 中建立不依赖 VS Code 的窗口活动状态机和退避纯函数,由 `extension.ts` 把状态转换映射为 timer/watcher 的启停。窗口失焦只停止 recurring resources,不中断正在运行的刷新;窗口重新聚焦时立即补刷新。 + +**Tech Stack:** TypeScript、VS Code Extension API、Node.js `node:test`、`fs.watch` + +## Global Constraints + +- 基线必须是公开 tag `v2.2.1`(`ade48ab721ee89fcb67cf35dfb9b8f820a61b0ac`)。 +- 不增加运行时依赖。 +- 不修改用户配置的 `refreshInterval` 和 `fileWatchSeconds`。 +- 不读取或写入 Claude 对话正文之外的新数据;本地 JSONL 保持只读。 +- 手动刷新和重新聚焦后的立即刷新必须保留。 +- OpenAI Codex 主要撰写的 commit 使用精确 trailer:`Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com>`。 + +--- + +### Task 1: 窗口活动状态机与 OAuth 退避纯函数 + +**Files:** +- Modify: `src/refreshPolicy.ts` +- Modify: `src/test/refreshPolicy.test.ts` + +**Interfaces:** +- Produces: `WindowActivityTransition = 'none' | 'resume' | 'suspend'` +- Produces: `new WindowActivityGate(initialFocused: boolean)` +- Produces: `WindowActivityGate.focused: boolean` +- Produces: `WindowActivityGate.update(nextFocused: boolean): WindowActivityTransition` +- Produces: `quotaFailureBackoffMs(failStreak: number): number` + +- [ ] **Step 1: 写状态机失败测试** + +```ts +test('window activity emits one suspend and one resume transition', () => { + const gate = new WindowActivityGate(true); + assert.equal(gate.update(true), 'none'); + assert.equal(gate.update(false), 'suspend'); + assert.equal(gate.update(false), 'none'); + assert.equal(gate.update(true), 'resume'); + assert.equal(gate.focused, true); +}); +``` + +- [ ] **Step 2: 运行测试并确认因缺少导出而失败** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +``` + +Expected: FAIL,提示 `WindowActivityGate` 尚未导出。 + +- [ ] **Step 3: 实现最小状态机** + +```ts +export type WindowActivityTransition = 'none' | 'resume' | 'suspend'; + +export class WindowActivityGate { + constructor(private active: boolean) {} + + get focused(): boolean { + return this.active; + } + + update(nextFocused: boolean): WindowActivityTransition { + if (nextFocused === this.active) return 'none'; + this.active = nextFocused; + return nextFocused ? 'resume' : 'suspend'; + } +} +``` + +- [ ] **Step 4: 写退避失败测试** + +```ts +test('quota failure backoff grows exponentially and caps at one hour', () => { + assert.deepEqual( + [0, 1, 2, 3, 6, 7, 20].map(quotaFailureBackoffMs), + [0, 60_000, 120_000, 240_000, 1_920_000, 3_600_000, 3_600_000], + ); +}); +``` + +- [ ] **Step 5: 运行测试并确认因缺少函数而失败** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +``` + +Expected: FAIL,提示 `quotaFailureBackoffMs` 尚未导出。 + +- [ ] **Step 6: 实现退避函数** + +```ts +export function quotaFailureBackoffMs(failStreak: number): number { + if (!Number.isFinite(failStreak) || failStreak <= 0) return 0; + const exponent = Math.max(0, Math.floor(failStreak) - 1); + return Math.min(3_600_000, 60_000 * Math.pow(2, exponent)); +} +``` + +- [ ] **Step 7: 编译并运行 focused tests** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +node --test out/test/refreshPolicy.test.js +``` + +Expected: PASS。 + +- [ ] **Step 8: 提交纯策略** + +```bash +git add src/refreshPolicy.ts src/test/refreshPolicy.test.ts +git commit -m "test: define foreground refresh energy policy" \ + -m "Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com>" +``` + +### Task 2: 将 Extension Host recurring resources 绑定到前台窗口 + +**Files:** +- Modify: `src/extension.ts` +- Create: `src/test/extensionWindowActivity.test.ts` + +**Interfaces:** +- Consumes: `WindowActivityGate` +- Produces: `ClaudeCodeUsageExtension.handleWindowFocusChange(focused: boolean): void` +- Produces: `ClaudeCodeUsageExtension.stopAutoRefresh(): void` +- Produces: `ClaudeCodeUsageExtension.suspendRecurringWork(): void` +- Produces: `ClaudeCodeUsageExtension.resumeRecurringWork(): void` + +- [ ] **Step 1: 写扩展状态转换失败测试** + +通过和现有 `extensionRefreshRouting.test.ts` 相同的 `vscode` module stub 加载真实 +`ClaudeCodeUsageExtension` prototype。使用 `Object.create()` 构造无副作用实例: + +```ts +test('background transition stops every recurring resource once', () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.windowActivity = new WindowActivityGate(true); + extension.stopAutoRefresh = () => calls.push('timer:stop'); + extension.stopFileWatching = () => calls.push('claude:stop'); + extension.stopCredentialsWatching = () => calls.push('credentials:stop'); + + extension.handleWindowFocusChange(false); + extension.handleWindowFocusChange(false); + + assert.deepEqual(calls, ['timer:stop', 'claude:stop', 'credentials:stop']); +}); + +test('foreground transition resumes resources and immediately catches up once', () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.windowActivity = new WindowActivityGate(false); + extension.startAutoRefresh = () => calls.push('timer:start'); + extension.startFileWatching = () => { + calls.push('claude:start'); + return Promise.resolve(); + }; + extension.startCredentialsWatching = () => calls.push('credentials:start'); + extension.refreshData = (_force: boolean, trigger: string) => { + calls.push(`refresh:${trigger}`); + return Promise.resolve(); + }; + + extension.handleWindowFocusChange(true); + extension.handleWindowFocusChange(true); + + assert.deepEqual(calls, [ + 'timer:start', + 'claude:start', + 'credentials:start', + 'refresh:focus', + ]); +}); +``` + +- [ ] **Step 2: 编译并确认测试因方法缺失而失败** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +``` + +Expected: FAIL,缺少窗口活动实现。 + +- [ ] **Step 3: 实现资源 suspend/resume** + +`extension.ts` 中加入: + +```ts +private readonly windowActivity = + new WindowActivityGate(vscode.window.state.focused); + +private stopAutoRefresh(): void { + this.refreshGen += 1; + if (this.refreshTimer) clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; +} + +private suspendRecurringWork(): void { + this.stopAutoRefresh(); + this.stopFileWatching(); + this.stopCredentialsWatching(); +} + +private resumeRecurringWork(): void { + this.startAutoRefresh(); + void this.startFileWatching(); + this.startCredentialsWatching(); + void this.refreshData(false, 'focus'); +} + +private handleWindowFocusChange(focused: boolean): void { + const transition = this.windowActivity.update(focused); + if (transition === 'suspend') this.suspendRecurringWork(); + if (transition === 'resume') this.resumeRecurringWork(); +} +``` + +`startWindowFocusRefresh()` 只注册 `handleWindowFocusChange(state.focused)`。 + +- [ ] **Step 4: 把启动和 watcher 创建限制在前台** + +构造函数只在 `windowActivity.focused` 时启动首次刷新和 recurring resources: + +```ts +if (this.windowActivity.focused) { + this.startAutoRefresh(); + void this.refreshData(false, 'startup').then(() => this.startFileWatching()); + this.startCredentialsWatching(); +} +``` + +`startAutoRefresh()`、`startFileWatching()` 和 `startCredentialsWatching()` 开头均在后台 +直接停止/返回。`startCredentialsWatching()` 创建新 watcher 前调用 +`stopCredentialsWatching()`,避免重复 watcher。 + +- [ ] **Step 5: 运行 focused tests** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +node --test out/test/extensionWindowActivity.test.js out/test/refreshPolicy.test.js +``` + +Expected: PASS。 + +- [ ] **Step 6: 提交窗口资源生命周期** + +```bash +git add src/extension.ts src/test/extensionWindowActivity.test.ts +git commit -m "fix: suspend refresh work in background windows" \ + -m "Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com>" +``` + +### Task 3: 连续 OAuth 失败退避与 credentials 恢复 + +**Files:** +- Modify: `src/extension.ts` +- Modify: `src/test/extensionWindowActivity.test.ts` + +**Interfaces:** +- Consumes: `quotaFailureBackoffMs(failStreak)` +- Produces: credentials 变化时 `usageLimitsFailStreak = 0` +- Produces: credentials 变化时 `usageLimitsBackoffUntil = new Date(0)` + +- [ ] **Step 1: 写 credentials 变化失败测试** + +使用可立即执行 debounce callback 的 timer stub,调用真实 credentials-change handler, +断言: + +```ts +assert.equal(extension.cache.usageLimitsFailStreak, 0); +assert.equal(extension.cache.usageLimitsBackoffUntil.getTime(), 0); +assert.deepEqual(calls, ['refresh:credentials']); +``` + +- [ ] **Step 2: 运行测试并确认旧 streak 未清除** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +node --test out/test/extensionWindowActivity.test.js +``` + +Expected: FAIL,旧 backoff 仍存在。 + +- [ ] **Step 3: 用纯函数替换 10 分钟上限并重置 credentials 状态** + +失败路径: + +```ts +this.cache.usageLimitsFailStreak += 1; +this.cache.usageLimitsBackoffUntil = new Date( + now + quotaFailureBackoffMs(this.cache.usageLimitsFailStreak), +); +``` + +credentials callback: + +```ts +this.cache.usageLimitsLastUpdate = new Date(0); +this.cache.usageLimitsFailStreak = 0; +this.cache.usageLimitsBackoffUntil = new Date(0); +void this.refreshData(false, 'credentials'); +``` + +- [ ] **Step 4: 运行 focused 和完整测试** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +node --test out/test/*.test.js .github/scripts/*.test.mjs +``` + +Expected: 0 failures。 + +- [ ] **Step 5: 提交 OAuth 节流** + +```bash +git add src/extension.ts src/test/extensionWindowActivity.test.ts +git commit -m "fix: back off repeated quota authentication failures" \ + -m "Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com>" +``` + +### Task 4: 发布说明、静态检查与交付 + +**Files:** +- Modify: `CHANGELOG.md` +- Verify: `package.json` +- Verify: `.vscodeignore` + +**Interfaces:** +- Produces: V2.2.2 changelog 中只描述本次后台能耗修复,不提前宣传 Codex Beta。 + +- [ ] **Step 1: 添加 V2.2.2 Unreleased 性能条目** + +```md +## [2.2.2] - Unreleased + +### Fixed + +- Suspend polling and file watchers in unfocused VS Code windows, then refresh + immediately when the window regains focus. +- Back off repeated quota authentication failures for longer while retrying + immediately after Claude credentials change. +``` + +- [ ] **Step 2: 运行完整验证** + +Run: + +```bash +node node_modules/typescript/bin/tsc -p ./ +node --test out/test/*.test.js .github/scripts/*.test.mjs +git diff --check +git status --short +``` + +Expected: 编译通过、0 failures、无 whitespace error,只有计划内文件变化。 + +- [ ] **Step 3: 提交发布说明** + +```bash +git add CHANGELOG.md +git commit -m "docs: record V2.2.2 energy efficiency fixes" \ + -m "Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com>" +``` + +- [ ] **Step 4: 准备 PR** + +```bash +git push -u origin codex/v2.2.2-energy-efficiency +gh pr create --repo ClaudeCodeUsage/ClaudeCodeUsage \ + --base main \ + --head Carl723000:codex/v2.2.2-energy-efficiency \ + --title "fix: reduce background VS Code energy use" \ + --body-file /private/tmp/v2.2.2-energy-pr.md +``` + +PR 正文必须列出双窗口复现、TDD 测试、V2.2.2 版本边界和 Codex 专项修复另随 +V2.3.0 发布。 diff --git a/docs/superpowers/specs/2026-07-30-v2.2.2-background-refresh-energy-design.zh-CN.md b/docs/superpowers/specs/2026-07-30-v2.2.2-background-refresh-energy-design.zh-CN.md new file mode 100644 index 00000000..c5b88248 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-v2.2.2-background-refresh-energy-design.zh-CN.md @@ -0,0 +1,109 @@ +# V2.2.2 后台刷新能耗修复设计 + +日期:2026-07-30 +状态:已批准,进入实施 + +## 背景 + +在同一台 Mac 上打开两个 VS Code 窗口时,每个窗口都有独立的 Extension +Host。V2.2.1 会在每个 Extension Host 中分别启动: + +- 周期轮询定时器; +- Claude `projects/**/*.jsonl` 递归文件监听; +- OAuth credentials 文件监听; +- OAuth `/usage` 获取与失败重试。 + +窗口失去焦点后,V2.2.1 只依赖 Electron 自身的定时器节流,并没有主动停止这些 +资源。重新获得焦点时又重新启动定时器和文件监听。结果是多个 VS Code 窗口会重复 +扫描同一份本地数据,并可能积累重复 watcher。 + +本机证据中,两个 VS Code 窗口对应两个 Extension Host。即使瞬时 CPU 较低,周期 +唤醒、文件元数据扫描和失败 OAuth 请求仍会累计为明显的 12 小时能耗。 + +## 目标 + +1. 同一时刻只有当前获得焦点的 VS Code 窗口执行周期轮询和文件监听。 +2. 窗口失焦时立即取消尚未触发的 timer/debounce 并关闭 watcher。 +3. 窗口重新获得焦点时立即补一次刷新,再恢复 timer 和 watcher。 +4. 手动刷新始终有效,不受后台节流影响。 +5. 连续 OAuth 失败采用最长 1 小时的指数退避;credentials 变化后立即清除退避。 +6. 不改变本地 JSONL 的只读约束、数据口径、状态栏格式和 dashboard 功能。 + +## 非目标 + +- 不引入常驻 daemon 或跨 VS Code 进程 IPC。 +- 不改变用户配置的 `refreshInterval` 数值。 +- 不把文件监听改成轮询。 +- 不改变成功 OAuth 请求的 30 秒保护下限和活跃/空闲 TTL。 +- 不在 V2.2.2 发布尚未公开的 Codex dashboard。 + +## 窗口活动状态机 + +新增一个无 VS Code 依赖的 `WindowActivityGate`,只负责记录窗口焦点状态并返回 +确定的状态转换: + +| 原状态 | 新状态 | 转换 | 扩展动作 | +|---|---|---|---| +| 前台 | 前台 | `none` | 不重复启动资源 | +| 后台 | 后台 | `none` | 不执行动作 | +| 前台 | 后台 | `suspend` | 停 timer、Claude watcher、credentials watcher 和 debounce | +| 后台 | 前台 | `resume` | 启动 timer/watcher,并触发一次 `focus` 刷新 | + +扩展启动时: + +- 当前窗口在前台:保持 V2.2.1 的启动刷新,但只创建一套 recurring resources。 +- 当前窗口在后台:不进行启动扫描,不创建 recurring resources;第一次获得焦点时 + 执行 `focus` 刷新。 + +正在执行的刷新不会在失焦瞬间被强制中断,避免留下半提交状态;但它完成后不会再次 +调度下一次周期刷新。 + +## OAuth 失败退避 + +连续失败采用: + +```text +1m, 2m, 4m, 8m, 16m, 32m, 60m, 60m... +``` + +成功请求清零 streak。credentials 文件发生变化时同时: + +- 清空 quota TTL; +- 清空 fail streak; +- 清空 backoff deadline; +- 执行一次 credentials 刷新。 + +这样无效/过期 refresh token 不会在每个窗口每 10 分钟持续唤醒网络,但用户完成 +重新登录后无需等待旧退避到期。 + +## 实现边界 + +- `src/refreshPolicy.ts` + - 新增 `WindowActivityGate`; + - 新增纯函数 `quotaFailureBackoffMs()`。 +- `src/extension.ts` + - 用 gate 驱动前后台状态转换; + - 抽取 `stopAutoRefresh()`; + - watcher 启动前检查窗口活动状态; + - credentials watcher 重启前关闭旧 watcher; + - credentials 变化时清除 OAuth 失败退避。 +- `src/test/refreshPolicy.test.ts` + - 状态机和退避边界测试。 +- `src/test/extensionWindowActivity.test.ts` + - 扩展真实状态转换的资源启停路由测试。 + +## 验收标准 + +1. 两个 VS Code 窗口中,后台窗口没有存活的 refresh timer、Claude watcher 或 + credentials watcher。 +2. `focused → unfocused` 只执行一次 suspend;重复失焦不重复关闭。 +3. `unfocused → focused` 只执行一次 resume,并立即触发一次 `focus` 刷新。 +4. 手动刷新仍绕过 dashboard 自动刷新开关。 +5. 第七次及后续连续 OAuth 失败的退避不超过且等于 1 小时。 +6. credentials 变化立即恢复尝试,不继承旧失败 streak。 +7. TypeScript 编译和完整 Node 测试通过。 + +## 版本安排 + +该修复进入 V2.2.2。Codex 索引无变化快速路径、跨窗口单写入和唯一临时文件属于 +未发布 Codex Beta 的专项修复,在本地 R6 完成并随 V2.3.0 发布。 diff --git a/src/extension.ts b/src/extension.ts index b0e9e980..5b2ce2d5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { import { commitRefreshSnapshot, pollIntervalMs, + quotaFailureBackoffMs, QuietDebounce, RefreshRequest, RefreshSingleFlight, @@ -37,6 +38,7 @@ import { reportColdRefreshFailure, shouldCommitUsageLoad, shouldReloadUsage, + WindowActivityGate, } from './refreshPolicy'; import { formatRefreshDiagnostic } from './refreshDiagnostics'; @@ -64,6 +66,8 @@ export class ClaudeCodeUsageExtension { private fileWatcher: fs.FSWatcher | undefined; private readonly watchDebounce = new QuietDebounce(); private readonly refreshGate = new RefreshSingleFlight(); + private readonly windowActivity = + new WindowActivityGate(vscode.window.state.focused); private watcherEventsSinceRefresh = 0; private coalescedTriggersSinceRefresh = 0; private watchedDir: string | null = null; @@ -133,9 +137,11 @@ export class ClaudeCodeUsageExtension { this.setupCommands(); this.loadConfiguration(); this.loadPersistedQuota(); - this.startAutoRefresh(); - this.refreshData(false, 'startup').then(() => this.startFileWatching()); - this.startCredentialsWatching(); + if (this.windowActivity.focused) { + this.startAutoRefresh(); + void this.refreshData(false, 'startup').then(() => this.startFileWatching()); + this.startCredentialsWatching(); + } this.startWindowFocusRefresh(); this.maybeAnnounceWhatsNew(); console.log('Claude Code Usage Extension: Initialization complete'); @@ -792,6 +798,10 @@ export class ClaudeCodeUsageExtension { * filesystems do not support recursive watching). */ private async startFileWatching(): Promise { + if (!this.windowActivity.focused) { + this.stopFileWatching(); + return; + } const config = this.getConfiguration(); if (!(config.fileWatchSeconds > 0)) { this.stopFileWatching(); // "Off" @@ -854,6 +864,11 @@ export class ClaudeCodeUsageExtension { * watch — those still self-correct on the next refresh tick. */ private startCredentialsWatching(): void { + if (!this.windowActivity.focused) { + this.stopCredentialsWatching(); + return; + } + this.stopCredentialsWatching(); const credsPath = this.apiClient.getCredentialsPath(); const dir = path.dirname(credsPath); const name = path.basename(credsPath); @@ -869,11 +884,7 @@ export class ClaudeCodeUsageExtension { clearTimeout(this.credsDebounceTimer); } this.credsDebounceTimer = setTimeout(() => { - // The cached quota belongs to the previous token/account. Expire it so - // the next refresh bypasses the TTL and refetches with the new token. - // The api client's own 429 cool-down still protects the endpoint. - this.cache.usageLimitsLastUpdate = new Date(0); - void this.refreshData(false, 'credentials'); + this.handleCredentialsChange(); }, 800); }); } catch { @@ -882,6 +893,15 @@ export class ClaudeCodeUsageExtension { } } + private handleCredentialsChange(): void { + // The cached quota and any failure backoff belong to the previous + // token/account. Clear both so a successful re-login retries immediately. + this.cache.usageLimitsLastUpdate = new Date(0); + this.cache.usageLimitsFailStreak = 0; + this.cache.usageLimitsBackoffUntil = new Date(0); + void this.refreshData(false, 'credentials'); + } + private stopCredentialsWatching(): void { if (this.credsDebounceTimer) { clearTimeout(this.credsDebounceTimer); @@ -902,32 +922,54 @@ export class ClaudeCodeUsageExtension { return Date.now() - this.lastActivityAt < 60000; } - /** Refresh when this window regains focus (#55). Each VS Code window is a - * separate extension host; Electron heavily throttles timers AND fs.watch - * delivery in unfocused/hidden windows, so a background window's status bar - * goes stale (only the window you last worked in keeps up). On focus we force - * an immediate refresh and re-arm the timer + watcher so the window catches up - * at once instead of after the next (throttled) tick — or never. */ + private suspendRecurringWork(): void { + this.stopAutoRefresh(); + this.stopFileWatching(); + this.stopCredentialsWatching(); + } + + private resumeRecurringWork(): void { + this.startAutoRefresh(); + void this.startFileWatching(); + this.startCredentialsWatching(); + void this.refreshData(false, 'focus'); + } + + private handleWindowFocusChange(focused: boolean): void { + const transition = this.windowActivity.update(focused); + if (transition === 'suspend') { + this.suspendRecurringWork(); + } else if (transition === 'resume') { + this.resumeRecurringWork(); + } + } + + /** Keep recurring work only in the active VS Code window. Each window owns a + * separate Extension Host, so leaving timers and watchers active in every + * background window multiplies the same local scans. A focused window catches + * up immediately; a background window stays idle until then. */ private startWindowFocusRefresh(): void { - let wasFocused = vscode.window.state.focused; this.context.subscriptions.push( vscode.window.onDidChangeWindowState((state) => { - if (state.focused && !wasFocused) { - this.startAutoRefresh(); // reset a cadence that may have been throttled - this.startFileWatching(); // re-attach the watcher if it was dropped - void this.refreshData(false, 'focus'); // catch up now - } - wasFocused = state.focused; + this.handleWindowFocusChange(state.focused); }) ); } - private startAutoRefresh(): void { + private stopAutoRefresh(): void { + this.refreshGen += 1; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } - const gen = ++this.refreshGen; + } + + private startAutoRefresh(): void { + this.stopAutoRefresh(); + if (!this.windowActivity.focused) { + return; + } + const gen = this.refreshGen; const tick = (): void => { if (gen !== this.refreshGen) { return; // superseded by a newer startAutoRefresh — stop this chain @@ -1007,9 +1049,10 @@ export class ClaudeCodeUsageExtension { ); return fetched; } - // Failed (usually a 429). Exponentially back off — 60s, 120s … capped at 10 min. + // Failed (usually a 429 or invalid/expired credentials). Exponentially back + // off to one hour; a credentials-file change clears this immediately. this.cache.usageLimitsFailStreak++; - const backoffMs = Math.min(600000, 60000 * Math.pow(2, this.cache.usageLimitsFailStreak - 1)); + const backoffMs = quotaFailureBackoffMs(this.cache.usageLimitsFailStreak); this.cache.usageLimitsBackoffUntil = new Date(now + backoffMs); return this.cache.usageLimits; } @@ -1289,10 +1332,7 @@ export class ClaudeCodeUsageExtension { } dispose(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; - } + this.stopAutoRefresh(); this.stopFileWatching(); this.stopCredentialsWatching(); this.statusBar.dispose(); diff --git a/src/refreshPolicy.ts b/src/refreshPolicy.ts index 9b1dc1b0..5c0d7edb 100644 --- a/src/refreshPolicy.ts +++ b/src/refreshPolicy.ts @@ -13,6 +13,28 @@ export type RefreshTrigger = | 'pricing' | 'manual'; +export type WindowActivityTransition = 'none' | 'resume' | 'suspend'; + +export class WindowActivityGate { + constructor(private active: boolean) {} + + get focused(): boolean { + return this.active; + } + + update(nextFocused: boolean): WindowActivityTransition { + if (nextFocused === this.active) return 'none'; + this.active = nextFocused; + return nextFocused ? 'resume' : 'suspend'; + } +} + +export function quotaFailureBackoffMs(failStreak: number): number { + if (!Number.isFinite(failStreak) || failStreak <= 0) return 0; + const exponent = Math.max(0, Math.floor(failStreak) - 1); + return Math.min(3_600_000, 60_000 * Math.pow(2, exponent)); +} + const TRIGGER_PRIORITY: Record = { startup: 0, poll: 1, diff --git a/src/test/extensionWindowActivity.test.ts b/src/test/extensionWindowActivity.test.ts new file mode 100644 index 00000000..e17901a7 --- /dev/null +++ b/src/test/extensionWindowActivity.test.ts @@ -0,0 +1,185 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { ClaudeDataLoader } from '../dataLoader'; +import { WindowActivityGate } from '../refreshPolicy'; + +type ExtensionModule = typeof import('../extension'); + +function loadExtensionModule(): ExtensionModule { + const moduleLoader = require('node:module') as { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; + }; + const originalLoad = moduleLoader._load; + const vscodeStub: any = new Proxy(function () {}, { + get: (_target, property) => property === 'then' ? undefined : vscodeStub, + apply: () => vscodeStub, + construct: () => vscodeStub, + }); + moduleLoader._load = function (request, parent, isMain): unknown { + if (request === 'vscode') { + return vscodeStub; + } + return Reflect.apply(originalLoad, this, [request, parent, isMain]); + }; + try { + return require('../extension') as ExtensionModule; + } finally { + moduleLoader._load = originalLoad; + } +} + +const { ClaudeCodeUsageExtension } = loadExtensionModule(); + +function bareExtension(): any { + return Object.create(ClaudeCodeUsageExtension.prototype) as any; +} + +test('background transition stops every recurring resource once', () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.windowActivity = new WindowActivityGate(true); + extension.stopAutoRefresh = () => calls.push('timer:stop'); + extension.stopFileWatching = () => calls.push('claude:stop'); + extension.stopCredentialsWatching = () => calls.push('credentials:stop'); + + extension.handleWindowFocusChange(false); + extension.handleWindowFocusChange(false); + + assert.deepEqual(calls, [ + 'timer:stop', + 'claude:stop', + 'credentials:stop', + ]); +}); + +test('foreground transition resumes resources and immediately catches up once', () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.windowActivity = new WindowActivityGate(false); + extension.startAutoRefresh = () => calls.push('timer:start'); + extension.startFileWatching = () => { + calls.push('claude:start'); + return Promise.resolve(); + }; + extension.startCredentialsWatching = () => calls.push('credentials:start'); + extension.refreshData = (_force: boolean, trigger: string) => { + calls.push(`refresh:${trigger}`); + return Promise.resolve(); + }; + + extension.handleWindowFocusChange(true); + extension.handleWindowFocusChange(true); + + assert.deepEqual(calls, [ + 'timer:start', + 'claude:start', + 'credentials:start', + 'refresh:focus', + ]); +}); + +test('background window cannot arm a new polling timer', () => { + const extension = bareExtension(); + extension.windowActivity = new WindowActivityGate(false); + extension.refreshGen = 0; + extension.refreshTimer = undefined; + extension.getConfiguration = () => ({ refreshInterval: 30 }); + extension.refreshData = () => Promise.resolve(); + + try { + extension.startAutoRefresh(); + assert.equal(extension.refreshTimer, undefined); + } finally { + extension.stopAutoRefresh(); + } +}); + +test('background window does not open a credentials watcher', () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.windowActivity = new WindowActivityGate(false); + extension.stopCredentialsWatching = () => calls.push('credentials:stop'); + extension.apiClient = { + getCredentialsPath: () => { + calls.push('credentials:path'); + return '/missing-parent/.credentials.json'; + }, + }; + + extension.startCredentialsWatching(); + + assert.deepEqual(calls, ['credentials:stop']); +}); + +test('background window does not inspect or watch the Claude projects tree', async () => { + const extension = bareExtension(); + const calls: string[] = []; + const originalFind = ClaudeDataLoader.findClaudeDataDirectory; + extension.windowActivity = new WindowActivityGate(false); + extension.stopFileWatching = () => calls.push('claude:stop'); + extension.getConfiguration = () => ({ + fileWatchSeconds: 30, + dataDirectory: '', + }); + (ClaudeDataLoader as any).findClaudeDataDirectory = async () => { + calls.push('claude:lookup'); + return null; + }; + + try { + await extension.startFileWatching(); + assert.deepEqual(calls, ['claude:stop']); + } finally { + (ClaudeDataLoader as any).findClaudeDataDirectory = originalFind; + } +}); + +test('credentials change clears quota failure backoff before refreshing', async () => { + const extension = bareExtension(); + const calls: string[] = []; + extension.cache = { + usageLimitsLastUpdate: new Date(123_000), + usageLimitsFailStreak: 6, + usageLimitsBackoffUntil: new Date(9_999_999), + }; + extension.refreshData = (_force: boolean, trigger: string) => { + calls.push(`refresh:${trigger}`); + return Promise.resolve(); + }; + + extension.handleCredentialsChange(); + await Promise.resolve(); + + assert.equal(extension.cache.usageLimitsLastUpdate.getTime(), 0); + assert.equal(extension.cache.usageLimitsFailStreak, 0); + assert.equal(extension.cache.usageLimitsBackoffUntil.getTime(), 0); + assert.deepEqual(calls, ['refresh:credentials']); +}); + +test('repeated quota failures use the one-hour backoff cap', async () => { + const extension = bareExtension(); + const originalNow = Date.now; + Date.now = () => 1_000_000; + extension.cache = { + usageLimits: null, + usageLimitsLastUpdate: new Date(0), + usageLimitsBackoffUntil: new Date(0), + usageLimitsFailStreak: 6, + }; + extension.apiClient = { + fetchUsageLimits: async () => null, + }; + extension.isActive = () => false; + + try { + const result = await extension.maybeFetchUsageLimits({ + usageLimitTracking: true, + }); + assert.equal(result, null); + assert.equal(extension.cache.usageLimitsFailStreak, 7); + assert.equal(extension.cache.usageLimitsBackoffUntil.getTime(), 4_600_000); + } finally { + Date.now = originalNow; + } +}); diff --git a/src/test/refreshPolicy.test.ts b/src/test/refreshPolicy.test.ts index 14145dba..41b1f93b 100644 --- a/src/test/refreshPolicy.test.ts +++ b/src/test/refreshPolicy.test.ts @@ -5,13 +5,31 @@ import { LIVE_REFRESH_SECONDS, mergeRefreshTrigger, pollIntervalMs, + quotaFailureBackoffMs, QuietDebounce, RefreshSingleFlight, reportColdRefreshFailure, shouldCommitUsageLoad, shouldReloadUsage, + WindowActivityGate, } from '../refreshPolicy'; +test('window activity emits one suspend and one resume transition', () => { + const gate = new WindowActivityGate(true); + assert.equal(gate.update(true), 'none'); + assert.equal(gate.update(false), 'suspend'); + assert.equal(gate.update(false), 'none'); + assert.equal(gate.update(true), 'resume'); + assert.equal(gate.focused, true); +}); + +test('quota failure backoff grows exponentially and caps at one hour', () => { + assert.deepEqual( + [0, 1, 2, 3, 6, 7, 20].map(quotaFailureBackoffMs), + [0, 60_000, 120_000, 240_000, 1_920_000, 3_600_000, 3_600_000], + ); +}); + test('poll interval always honors refreshInterval and never applies an active override', () => { assert.equal(pollIntervalMs(30), 30_000); assert.equal(pollIntervalMs(60), 60_000); diff --git a/src/test/repositoryPolicy.test.ts b/src/test/repositoryPolicy.test.ts index da14ca62..b1dc395b 100644 --- a/src/test/repositoryPolicy.test.ts +++ b/src/test/repositoryPolicy.test.ts @@ -117,9 +117,12 @@ test('pull request checklist names the actual eight UI locales', () => { assert.match(template, /all seven README editions/); }); -test('changelog records the released baseline and the v2.2.1 tooling transition', () => { +test('changelog records the V2.2.2 energy patch after the released V2.2.1 baseline', () => { const changelog = repoFile('CHANGELOG.md'); - assert.match(changelog, /^## \[2\.2\.1\] — Unreleased$/m); + assert.match(changelog, /^## \[2\.2\.2\] — Unreleased$/m); + assert.match(changelog, /^## \[2\.2\.1\] — 2026-07-18$/m); + assert.match(changelog, /Suspend polling and file watchers in\s+unfocused VS Code windows/); + assert.match(changelog, /Back off repeated quota authentication failures/); assert.match(changelog, /^## \[2\.2\.0\] — 2026-07-07$/m); assert.match(changelog, /OpenAI Codex/); assert.doesNotMatch(changelog, /^## \[2\.2\.0\] — Unreleased$/m); From cbee90e754f99917be11dfd6c92d398a79ac0103 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:25:45 +0100 Subject: [PATCH 4/4] docs: finalize v2.2.2 release notes (#85) Move the post-v2.2.1 contributor fixes into the v2.2.2 section while preserving their PR attribution. Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- CHANGELOG.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c99673..a922a616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel - **Quota failure throttling** — Back off repeated quota authentication failures for up to one hour, while retrying immediately after Claude credentials change. +- **Usage dashboard recovery (#79, fixes #82)** — one oversized non-transcript + `.jsonl` can no longer abort the earliest-timestamp probe and blank the whole + dashboard. Thanks [@ptweezy](https://github.com/ptweezy). +- **Opus 5 context window (#81, reported in #84)** — recognise the bare + `claude-opus-5` model id as a 1M-context model and remove its spurious + unknown-model pricing diagnostic. Thanks [@e7d](https://github.com/e7d). ## [2.2.1] — 2026-07-18 @@ -35,23 +41,6 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel are credited as development tools, separately from human contributors. ### Fixed -- **Usage dashboard no longer blanks on one oversized non-transcript file** — a - single `.jsonl` with more than 1 MiB of leading lines that carry no - `timestamp` (such as a large workflow `journal.jsonl`, whose records hold - `type`/`key`/`result`/`agentId` and never a timestamp) made the - earliest-timestamp probe throw `TimestampProbeLimitError`. That rejection was - unhandled, so it aborted the whole load and left the dashboard empty on every - refresh (`failed=1`, `bytes=0` in diagnostics). The probe now fails soft for - that one file and the rest load normally. -- **Opus 5 context window** — `claude-opus-5` reported a 200 000-token window - as if it were exact, so the status bar and dashboard showed a context - percentage five times too high. Claude Code writes the plain `claude-opus-5` - id to its JSONL even for the 1M-context variant, so the existing `[1m]` - marker never covered it. The major-version check now recognises Opus 5+ - alongside Sonnet 5+, and `claude-opus-5` is an explicit entry in the pricing - table (same $5 / $25 current-Opus tier the family fallback already resolved, - minus the spurious `Unknown model` diagnostic). - - **High-CPU refresh mitigation (#70)** — polling now always honors the configured 30–3600 second `refreshInterval`; file watching is quiet-debounce only and adds 60/120/300-second choices. First-timestamp reads stop after the