From 53ee3a199e83144038ab071994b1058a52d34cb6 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:21:45 +0100 Subject: [PATCH 1/5] docs: specify V2.2.2 background energy fixes Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- ...-v2.2.2-background-refresh-energy.zh-CN.md | 397 ++++++++++++++++++ ...-background-refresh-energy-design.zh-CN.md | 109 +++++ 2 files changed, 506 insertions(+) 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 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 发布。 From 49043e4508147d73ed4b3cb0eeacef79ee04b20f Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:23:21 +0100 Subject: [PATCH 2/5] test: define foreground refresh energy policy Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- src/refreshPolicy.ts | 22 ++++++++++++++++++++++ src/test/refreshPolicy.test.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) 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/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); From f0d8a65cca501f845d0601088ca5ab9a64d07375 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:26:06 +0100 Subject: [PATCH 3/5] fix: suspend refresh work in background windows Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- src/extension.ts | 77 +++++++++---- src/test/extensionWindowActivity.test.ts | 136 +++++++++++++++++++++++ 2 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 src/test/extensionWindowActivity.test.ts diff --git a/src/extension.ts b/src/extension.ts index b0e9e980..40c5a3e9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -37,6 +37,7 @@ import { reportColdRefreshFailure, shouldCommitUsageLoad, shouldReloadUsage, + WindowActivityGate, } from './refreshPolicy'; import { formatRefreshDiagnostic } from './refreshDiagnostics'; @@ -64,6 +65,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 +136,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 +797,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 +863,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); @@ -902,32 +916,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 @@ -1289,10 +1325,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/test/extensionWindowActivity.test.ts b/src/test/extensionWindowActivity.test.ts new file mode 100644 index 00000000..4fedc3a1 --- /dev/null +++ b/src/test/extensionWindowActivity.test.ts @@ -0,0 +1,136 @@ +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; + } +}); From 47e5bb6ba8bb87f9627ebce0b8b47549e9785b1f Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:27:22 +0100 Subject: [PATCH 4/5] fix: back off repeated quota authentication failures Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- src/extension.ts | 21 ++++++---- src/test/extensionWindowActivity.test.ts | 49 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 40c5a3e9..5b2ce2d5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { import { commitRefreshSnapshot, pollIntervalMs, + quotaFailureBackoffMs, QuietDebounce, RefreshRequest, RefreshSingleFlight, @@ -883,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 { @@ -896,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); @@ -1043,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; } diff --git a/src/test/extensionWindowActivity.test.ts b/src/test/extensionWindowActivity.test.ts index 4fedc3a1..e17901a7 100644 --- a/src/test/extensionWindowActivity.test.ts +++ b/src/test/extensionWindowActivity.test.ts @@ -134,3 +134,52 @@ test('background window does not inspect or watch the Claude projects tree', asy (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; + } +}); From 7ee505cea97da5f3405500dcea6fd4a334bfceec Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:30:01 +0100 Subject: [PATCH 5/5] docs: record V2.2.2 energy efficiency fixes Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- .github/scripts/automationPolicy.test.mjs | 4 ++-- CHANGELOG.md | 12 +++++++++++- src/test/repositoryPolicy.test.ts | 7 +++++-- 3 files changed, 18 insertions(+), 5 deletions(-) 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 09c00545..084ea1e2 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/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);