From cb3b641be02683ec77010d70fc9325241d5d74b2 Mon Sep 17 00:00:00 2001 From: saime <2286263079@qq.com> Date: Tue, 28 Jul 2026 02:39:04 -1000 Subject: [PATCH 1/3] fix: detect Windows Store Claude usage data --- lib/usage.js | 101 +++++++++++++++++++++++++++++++++++++--------- lib/usage.test.js | 73 +++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 23 deletions(-) diff --git a/lib/usage.js b/lib/usage.js index c4c21cd..9591d60 100644 --- a/lib/usage.js +++ b/lib/usage.js @@ -20,6 +20,39 @@ const DEFAULT_DESKTOP_SESSIONS_ROOT = path.join( ); const PRICE_SNAPSHOT = '2026-07-26'; +function discoverDesktopData({ + appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), +} = {}) { + const roots = [path.join(appData, 'Claude')]; + try { + const packages = path.join(localAppData, 'Packages'); + for (const entry of fs.readdirSync(packages, { withFileTypes: true })) { + if (entry.isDirectory() && /^Claude_/i.test(entry.name)) { + roots.push(path.join(packages, entry.name, 'LocalCache', 'Roaming', 'Claude')); + } + } + } catch { + // Microsoft Store package data is optional. + } + const candidates = roots.map((root) => ({ + usagePath: path.join(root, 'plan-usage-history.json'), + sessionsRoot: path.join(root, 'claude-code-sessions'), + })); + const modified = (filePath) => { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } + }; + return candidates.sort( + (a, b) => + Math.max(modified(b.usagePath), modified(b.sessionsRoot)) - + Math.max(modified(a.usagePath), modified(a.sessionsRoot)), + )[0]; +} + // USD per 1M tokens — Anthropic standard API list prices (snapshot 2026-07-26). // Cache write bills at 1.25x input (5m TTL) or 2x (1h TTL); cache read is 0.1x. // Pro/Max subscribers aren't billed per token — we surface this as @@ -101,25 +134,31 @@ function readCwd(filePath) { } } -function scanFile(filePath, today, seen) { +function scanFile(filePath, today, seen, findActivity = false) { // ponytail: full-file reread on every refresh; switch to per-file byte // offsets if transcripts ever make refresh feel slow let text; try { text = fs.readFileSync(filePath, 'utf8'); } catch { - return; + return null; } + let latestActivity = null; const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (!line || !line.includes('"usage"')) continue; + if (!line || (!findActivity && !line.includes('"usage"'))) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } + if (findActivity && (entry.type === 'user' || entry.type === 'assistant')) { + const timestamp = new Date(entry.timestamp).getTime(); + if (Number.isFinite(timestamp)) latestActivity = Math.max(latestActivity || 0, timestamp); + } + if (!line.includes('"usage"')) continue; if (entry.type !== 'assistant' || !entry.timestamp) continue; const msg = entry.message; if (!msg || !msg.usage || !msg.model) continue; @@ -127,6 +166,7 @@ function scanFile(filePath, today, seen) { // Streaming rewrites the same message id with growing usage — last wins. seen.set(msg.id || `${filePath}:${i}`, { model: msg.model, usage: msg.usage }); } + return latestActivity; } // States written by hooks/report-status.js (wired in ~/.claude/settings.json): @@ -265,6 +305,23 @@ function jsonFiles(root) { }); } +function transcriptFiles(root) { + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + return entries.flatMap((entry) => { + const filePath = path.join(root, entry.name); + return entry.isDirectory() + ? transcriptFiles(filePath) + : entry.name.endsWith('.jsonl') + ? [filePath] + : []; + }); +} + function readDesktopSessions(root) { // ponytail: Desktop stores dozens of small metadata files; add an mtime index only if this scan becomes measurable. const sessions = new Map(); @@ -296,10 +353,15 @@ function readDesktopSessions(root) { function collectUsage({ root = DEFAULT_ROOT, statusDir = DEFAULT_STATUS_DIR, - desktopUsagePath = DEFAULT_DESKTOP_USAGE_PATH, - desktopSessionsRoot = DEFAULT_DESKTOP_SESSIONS_ROOT, + desktopUsagePath, + desktopSessionsRoot, now = new Date(), } = {}) { + if (!desktopUsagePath || !desktopSessionsRoot) { + const desktopData = discoverDesktopData(); + desktopUsagePath ||= desktopData.usagePath; + desktopSessionsRoot ||= desktopData.sessionsRoot; + } const cliRateLimits = readRateLimits(statusDir, now); const desktopRateLimits = readDesktopRateLimits(desktopUsagePath, now); const today = dayKey(now); @@ -324,26 +386,29 @@ function collectUsage({ const seen = new Map(); for (const dir of fs.readdirSync(root)) { const dirPath = path.join(root, dir); - let files; - try { - files = fs.readdirSync(dirPath); - } catch { - continue; // not a directory - } - for (const f of files) { - if (!f.endsWith('.jsonl')) continue; - const filePath = path.join(dirPath, f); + for (const filePath of transcriptFiles(dirPath)) { + const direct = path.dirname(filePath) === dirPath; let st; try { st = fs.statSync(filePath); } catch { continue; } - if (st.mtimeMs >= now.getTime() - 24 * 3600 * 1000) { + const recent = direct && st.mtimeMs >= now.getTime() - 24 * 3600 * 1000; + const modifiedToday = st.mtimeMs >= startOfDay; + if (!recent && !modifiedToday) continue; + const transcriptActivityAt = scanFile(filePath, today, seen, recent); + if (recent) { + const f = path.basename(filePath); const sessionId = f.slice(0, -6); // ".jsonl" const hook = statuses.get(sessionId); const desktop = desktopSessions.get(sessionId); - const activityAt = Math.max(st.mtimeMs, Number.isFinite(desktop && desktop.lastActivityAt) ? desktop.lastActivityAt : 0); + const activityAt = + Math.max( + transcriptActivityAt || 0, + Number.isFinite(desktop && desktop.lastActivityAt) ? desktop.lastActivityAt : 0, + ) || st.mtimeMs; + if (activityAt < now.getTime() - 24 * 3600 * 1000) continue; const currentHook = hook && hook.ts + 1000 >= activityAt ? hook : null; const cwd = (currentHook && currentHook.cwd) || readCwd(filePath); const fresh = now.getTime() - activityAt < 2 * 60 * 1000; @@ -361,9 +426,6 @@ function collectUsage({ message: (currentHook && currentHook.message) || null, }); } - // today's entries can only live in files modified today - if (st.mtimeMs < startOfDay) continue; - scanFile(filePath, today, seen); } } @@ -407,6 +469,7 @@ function collectUsage({ module.exports = { collectUsage, + discoverDesktopData, readStatuses, readRateLimits, readDesktopRateLimits, diff --git a/lib/usage.test.js b/lib/usage.test.js index 5cfc7c7..0be44f2 100644 --- a/lib/usage.test.js +++ b/lib/usage.test.js @@ -5,7 +5,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); -const { collectUsage, priceFor } = require('./usage'); +const { collectUsage, discoverDesktopData, priceFor } = require('./usage'); const { collectCodexUsage, costOf: codexCostOf, @@ -28,7 +28,7 @@ function fixture(lines, { sessionId = 'session-1' } = {}) { const file = path.join(proj, `${sessionId}.jsonl`); fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); fs.utimesSync(file, NOW, NOW); - return { root, statusDir, file, sessionId }; + return { root, statusDir, proj, file, sessionId }; } function codexFixture(lines) { @@ -145,6 +145,37 @@ test('files not modified today are skipped for usage but old sessions drop off', assert.equal(u.sessions.length, 0, 'older than 24h not listed'); }); +test('nested subagent transcripts are included without creating extra sessions', () => { + const { root, statusDir, proj, sessionId } = fixture([ + entry('parent', 'claude-opus-5', { input_tokens: 10, output_tokens: 5 }), + ]); + const nestedDir = path.join(proj, sessionId, 'subagents'); + fs.mkdirSync(nestedDir, { recursive: true }); + const nestedFile = path.join(nestedDir, 'agent-child.jsonl'); + fs.writeFileSync( + nestedFile, + JSON.stringify(entry('child', 'claude-opus-5', { input_tokens: 20, output_tokens: 7 })) + '\n', + ); + fs.utimesSync(nestedFile, NOW, NOW); + + const u = collect({ root, statusDir, now: NOW }); + assert.equal(u.totals.requests, 2); + assert.equal(u.totals.input, 30); + assert.equal(u.sessions.length, 1, 'subagents belong to their parent session'); +}); + +test('metadata-only writes do not make an old transcript active', () => { + const old = new Date(NOW.getTime() - 10 * 60 * 1000); + const { root, statusDir } = fixture([ + entry('old', 'claude-opus-5', { input_tokens: 10, output_tokens: 5 }, old.toISOString()), + { type: 'mode', mode: 'default', sessionId: 'session-1' }, + ]); + + const session = collect({ root, statusDir, now: NOW }).sessions[0]; + assert.equal(session.mtime, old.getTime()); + assert.equal(session.state, 'idle'); +}); + test('missing root returns empty result', () => { const u = collect({ root: path.join(os.tmpdir(), 'does-not-exist-xyz'), @@ -316,14 +347,21 @@ test('Desktop session links are validated and Claude falls back to copying its t }); test('hook status overrides the mtime heuristic; stale status is ignored', () => { + const beforeHook = new Date(NOW.getTime() - 10_000); const { root, statusDir, file, sessionId } = fixture( - [entry('m1', 'claude-opus-5', { input_tokens: 1, output_tokens: 1 })], + [ + entry( + 'm1', + 'claude-opus-5', + { input_tokens: 1, output_tokens: 1 }, + new Date(NOW.getTime() - 20_000).toISOString(), + ), + ], { sessionId: 'abc-123' }, ); const statusFile = path.join(statusDir, `${sessionId}.json`); fs.writeFileSync(statusFile, JSON.stringify({ state: 'attention' })); assert.equal(collect({ root, statusDir, now: NOW }).sessions[0].fromHook, false); - const beforeHook = new Date(NOW.getTime() - 10_000); fs.utimesSync(file, beforeHook, beforeHook); fs.writeFileSync( statusFile, @@ -347,6 +385,10 @@ test('hook status overrides the mtime heuristic; stale status is ignored', () => assert.equal(u.sessions[0].fromHook, true); assert.equal(u.sessions[0].message, 'Claude needs your permission'); + fs.appendFileSync( + file, + JSON.stringify(entry('m2', 'claude-opus-5', { input_tokens: 1, output_tokens: 1 })) + '\n', + ); fs.utimesSync(file, NOW, NOW); const active = collect({ root, statusDir, now: NOW }).sessions[0]; assert.equal(active.state, 'working'); @@ -427,6 +469,29 @@ test('statusLine reporter captures official rate limits; expired windows are hid assert.equal(collect({ root, statusDir, now: later }).rateLimits.fiveHour, null); }); +test('Microsoft Store Claude Desktop data is discovered under LocalCache', () => { + const appData = fs.mkdtempSync(path.join(os.tmpdir(), 'cut-appdata-')); + const localAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'cut-localappdata-')); + const desktopRoot = path.join( + localAppData, + 'Packages', + 'Claude_test-package', + 'LocalCache', + 'Roaming', + 'Claude', + ); + fs.mkdirSync(desktopRoot, { recursive: true }); + fs.writeFileSync( + path.join(desktopRoot, 'plan-usage-history.json'), + JSON.stringify({ version: 2, samples: [{ t: NOW.getTime(), u: { fh: 12, sd: 34 } }] }), + ); + + assert.deepEqual(discoverDesktopData({ appData, localAppData }), { + usagePath: path.join(desktopRoot, 'plan-usage-history.json'), + sessionsRoot: path.join(desktopRoot, 'claude-code-sessions'), + }); +}); + test('Desktop history is auto-detected and the freshest source wins', () => { const { root, statusDir } = fixture([]); const desktopUsagePath = path.join(statusDir, 'plan-usage-history.json'); From dc21aa092a8283613162dba2b15320c9b0e95932 Mon Sep 17 00:00:00 2001 From: saime <2286263079@qq.com> Date: Tue, 28 Jul 2026 03:11:35 -1000 Subject: [PATCH 2/3] release: prepare v1.0.2 --- README.md | 6 ++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 58cb4f7..1ee7dbb 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ 4. 右键悬浮条或托盘图标,可刷新、切换顶部/右侧、隐藏悬浮条或退出。 > [!WARNING] -> v1.0.1 及更早版本尚未进行 Windows 代码签名,SmartScreen 可能显示提醒。请只从本仓库的 Releases 下载,并核对 Release 中提供的 SHA-256。后续签名版本将按下方 [Code signing policy](#code-signing-policy) 发布。 +> 当前便携版尚未进行 Windows 代码签名,SmartScreen 可能显示提醒。请只从本仓库的 Releases 下载,并核对 Release 中提供的 SHA-256。后续签名版本将按下方 [Code signing policy](#code-signing-policy) 发布。 ## 数据从哪里来 @@ -53,6 +53,8 @@ | Claude 账户(可选) | Anthropic OAuth 用量接口 | 官方百分比与精确重置时间 | | Codex CLI / Desktop | `~/.codex/sessions/**/*.jsonl` | token、额度窗口、模型、会话活动 | +Microsoft Store 版 Claude Desktop 会自动读取 `%LOCALAPPDATA%/Packages/Claude_*/LocalCache/Roaming/Claude/` 下的同名数据文件。 + ### 重置时间与刷新频率 - 应用每 **30 秒**重新读取一次本地数据。 @@ -133,7 +135,7 @@ git status --short - Windows x64 only。 - 尚未接入自动更新。 -- v1.0.1 及更早版本尚未进行代码签名;SignPath Foundation 申请和自动签名接入正在进行。 +- 当前便携版尚未进行代码签名;SignPath Foundation 申请和自动签名接入正在进行。 - Claude OAuth 可能受 Anthropic 限流或当前网络出口影响;本地推算不受影响。 ## 贡献 diff --git a/package-lock.json b/package-lock.json index 6b33933..7e2454e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-code-usage-tray", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-code-usage-tray", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "devDependencies": { "electron": "43.2.0", diff --git a/package.json b/package.json index fc182da..67eb40b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-code-usage-tray", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "Windows tray monitor for Claude Code and Codex usage and session activity", "author": "saixin", From c62e5cbcd04c95034d688cc683b053afd1f5d9bf Mon Sep 17 00:00:00 2001 From: saime <2286263079@qq.com> Date: Tue, 28 Jul 2026 03:20:32 -1000 Subject: [PATCH 3/3] fix: let electron-builder resolve Electron --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 67eb40b..6a4716f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "appId": "com.aicode.usage-tray", "productName": "AI Code Usage Tray", "asar": true, - "electronDist": "node_modules/electron/dist", "files": [ "main.js", "preload.js",