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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 发布。

## 数据从哪里来

Expand All @@ -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 秒**重新读取一次本地数据。
Expand Down Expand Up @@ -133,7 +135,7 @@ git status --short

- Windows x64 only。
- 尚未接入自动更新。
- v1.0.1 及更早版本尚未进行代码签名;SignPath Foundation 申请和自动签名接入正在进行。
- 当前便携版尚未进行代码签名;SignPath Foundation 申请和自动签名接入正在进行。
- Claude OAuth 可能受 Anthropic 限流或当前网络出口影响;本地推算不受影响。

## 贡献
Expand Down
101 changes: 82 additions & 19 deletions lib/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,32 +134,39 @@ 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;
if (dayKey(new Date(entry.timestamp)) !== today) continue;
// 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):
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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); // "<uuid>.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;
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -407,6 +469,7 @@ function collectUsage({

module.exports = {
collectUsage,
discoverDesktopData,
readStatuses,
readRateLimits,
readDesktopRateLimits,
Expand Down
73 changes: 69 additions & 4 deletions lib/usage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
Loading