@zhangferry-dev/tokendash 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{index-BPWY9q0y.js → index-CY4G_b0x.js} +47 -47
- package/dist/client/assets/index-_yA9tOzZ.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/client/popover.html +822 -395
- package/dist/electron-server.cjs +120 -55
- package/dist/electron-server.cjs.map +2 -2
- package/dist/server/claudeBlocksParser.js +2 -2
- package/dist/server/claudeJsonlParser.d.ts +1 -1
- package/dist/server/claudeJsonlParser.js +22 -19
- package/dist/server/codexParser.js +72 -32
- package/dist/server/index.js +18 -3
- package/dist/server/routes/api.d.ts +6 -1
- package/dist/server/routes/api.js +11 -1
- package/electron/main.cjs +67 -56
- package/electron/npmSync.cjs +62 -0
- package/electron/preload.cjs +11 -0
- package/electron/serverReuse.cjs +59 -0
- package/electron/updateService.cjs +220 -0
- package/package.json +1 -1
- package/dist/client/assets/index-iYDpTV63.css +0 -1
- package/dist/server/ccusage.d.ts +0 -7
- package/dist/server/ccusage.js +0 -69
- package/electron/main.js +0 -291
- package/electron/trayBadge.js +0 -30
- package/resources/entitlements.mac.plist +0 -10
- package/resources/icon.png +0 -0
|
@@ -142,7 +142,7 @@ export function getClaudeBlocksByProject(project) {
|
|
|
142
142
|
const outputTokens = usage.output_tokens;
|
|
143
143
|
const cacheCreationTokens = usage.cache_creation_input_tokens;
|
|
144
144
|
const cacheReadTokens = usage.cache_read_input_tokens;
|
|
145
|
-
const totalTokens = inputTokens + outputTokens + cacheReadTokens;
|
|
145
|
+
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
146
146
|
if (totalTokens === 0)
|
|
147
147
|
continue;
|
|
148
148
|
const hourKey = getHourKey(event.timestamp);
|
|
@@ -167,7 +167,7 @@ export function getClaudeBlocksByProject(project) {
|
|
|
167
167
|
const blocks = [];
|
|
168
168
|
let idx = 0;
|
|
169
169
|
for (const [hourKey, bucket] of hourMap) {
|
|
170
|
-
const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens;
|
|
170
|
+
const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheCreationTokens + bucket.cacheReadTokens;
|
|
171
171
|
blocks.push({
|
|
172
172
|
id: `claude-project-${idx}`,
|
|
173
173
|
startTime: `${hourKey}:00:00.000Z`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DailyResponse, ProjectsResponse, BlockEntry } from '../shared/types.js';
|
|
2
|
-
export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string): number;
|
|
2
|
+
export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string, cacheCreationTokens?: number): number;
|
|
3
3
|
/** Decode Claude's encoded project directory name.
|
|
4
4
|
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
5
5
|
* Since '-' replaces '/' and project names can contain '-',
|
|
@@ -3,18 +3,18 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
const MODEL_PRICING = {
|
|
5
5
|
// Claude 4.6
|
|
6
|
-
'claude-opus-4-6': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
7
|
-
'claude-sonnet-4-6': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
6
|
+
'claude-opus-4-6': { inputPer1M: 15, cacheCreationPer1M: 18.75, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
7
|
+
'claude-sonnet-4-6': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
8
8
|
// Claude 4.5
|
|
9
|
-
'claude-sonnet-4-5-20250514': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
10
|
-
'claude-haiku-4-5-20251001': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
9
|
+
'claude-sonnet-4-5-20250514': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
10
|
+
'claude-haiku-4-5-20251001': { inputPer1M: 0.80, cacheCreationPer1M: 1, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
11
11
|
// Older Claude models
|
|
12
|
-
'claude-3-5-sonnet-20241022': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
13
|
-
'claude-3-5-haiku-20241022': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
14
|
-
'claude-3-opus-20240229': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
15
|
-
'claude-3-haiku-20240307': { inputPer1M: 0.25, cacheReadPer1M: 0.03, outputPer1M: 1.25 },
|
|
12
|
+
'claude-3-5-sonnet-20241022': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
13
|
+
'claude-3-5-haiku-20241022': { inputPer1M: 0.80, cacheCreationPer1M: 1, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
14
|
+
'claude-3-opus-20240229': { inputPer1M: 15, cacheCreationPer1M: 18.75, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
15
|
+
'claude-3-haiku-20240307': { inputPer1M: 0.25, cacheCreationPer1M: 0.30, cacheReadPer1M: 0.03, outputPer1M: 1.25 },
|
|
16
16
|
};
|
|
17
|
-
const DEFAULT_PRICING = { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 };
|
|
17
|
+
const DEFAULT_PRICING = { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 };
|
|
18
18
|
function getPricing(model) {
|
|
19
19
|
// Try exact match first, then prefix match
|
|
20
20
|
if (MODEL_PRICING[model])
|
|
@@ -26,13 +26,16 @@ function getPricing(model) {
|
|
|
26
26
|
}
|
|
27
27
|
return DEFAULT_PRICING;
|
|
28
28
|
}
|
|
29
|
-
export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model) {
|
|
29
|
+
export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model, cacheCreationTokens = 0) {
|
|
30
30
|
const p = getPricing(model);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return (inputTokens / 1_000_000) * p.inputPer1M
|
|
32
|
+
+ (cacheCreationTokens / 1_000_000) * p.cacheCreationPer1M
|
|
33
33
|
+ (cacheReadTokens / 1_000_000) * p.cacheReadPer1M
|
|
34
34
|
+ (outputTokens / 1_000_000) * p.outputPer1M;
|
|
35
35
|
}
|
|
36
|
+
function totalClaudeTokens(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
|
|
37
|
+
return inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
38
|
+
}
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// JSONL parsing with mtime cache
|
|
38
41
|
// ---------------------------------------------------------------------------
|
|
@@ -160,7 +163,7 @@ function parseAllSessions(project) {
|
|
|
160
163
|
const outputTokens = usage.output_tokens || 0;
|
|
161
164
|
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
162
165
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
163
|
-
const totalTokens = inputTokens
|
|
166
|
+
const totalTokens = totalClaudeTokens(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
|
|
164
167
|
if (totalTokens === 0)
|
|
165
168
|
continue;
|
|
166
169
|
entries.push({
|
|
@@ -248,8 +251,8 @@ export function getDailyResponse(project, tz = DEFAULT_TZ) {
|
|
|
248
251
|
agg.outputTokens += e.outputTokens;
|
|
249
252
|
agg.cacheCreationTokens += e.cacheCreationTokens;
|
|
250
253
|
agg.cacheReadTokens += e.cacheReadTokens;
|
|
251
|
-
agg.totalTokens += e.inputTokens
|
|
252
|
-
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
254
|
+
agg.totalTokens += totalClaudeTokens(e.inputTokens, e.outputTokens, e.cacheCreationTokens, e.cacheReadTokens);
|
|
255
|
+
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
253
256
|
agg.totalCost += cost;
|
|
254
257
|
if (!agg.models.has(e.model)) {
|
|
255
258
|
agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
|
|
@@ -294,8 +297,8 @@ export function getProjectsResponse(tz = DEFAULT_TZ) {
|
|
|
294
297
|
agg.outputTokens += e.outputTokens;
|
|
295
298
|
agg.cacheCreationTokens += e.cacheCreationTokens;
|
|
296
299
|
agg.cacheReadTokens += e.cacheReadTokens;
|
|
297
|
-
agg.totalTokens += e.inputTokens
|
|
298
|
-
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
300
|
+
agg.totalTokens += totalClaudeTokens(e.inputTokens, e.outputTokens, e.cacheCreationTokens, e.cacheReadTokens);
|
|
301
|
+
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
299
302
|
agg.totalCost += cost;
|
|
300
303
|
if (!agg.models.has(e.model)) {
|
|
301
304
|
agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
|
|
@@ -331,13 +334,13 @@ export function getBlocksResponse(project, tz = DEFAULT_TZ) {
|
|
|
331
334
|
bucket.outputTokens += e.outputTokens;
|
|
332
335
|
bucket.cacheCreationTokens += e.cacheCreationTokens;
|
|
333
336
|
bucket.cacheReadTokens += e.cacheReadTokens;
|
|
334
|
-
bucket.costUSD += calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
337
|
+
bucket.costUSD += calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
335
338
|
bucket.models.add(e.model);
|
|
336
339
|
}
|
|
337
340
|
const blocks = [];
|
|
338
341
|
let idx = 0;
|
|
339
342
|
for (const [hourKey, bucket] of hourMap) {
|
|
340
|
-
const totalTokens = bucket.inputTokens
|
|
343
|
+
const totalTokens = totalClaudeTokens(bucket.inputTokens, bucket.outputTokens, bucket.cacheCreationTokens, bucket.cacheReadTokens);
|
|
341
344
|
blocks.push({
|
|
342
345
|
id: `claude-${idx}`,
|
|
343
346
|
startTime: `${hourKey}:00:00`,
|
|
@@ -15,20 +15,24 @@ const TokenUsageSchema = z.object({
|
|
|
15
15
|
}).default({ input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 });
|
|
16
16
|
const TokenCountInfoSchema = z.object({
|
|
17
17
|
total_token_usage: TokenUsageSchema,
|
|
18
|
-
last_token_usage: TokenUsageSchema,
|
|
18
|
+
last_token_usage: TokenUsageSchema.optional(),
|
|
19
19
|
}).nullable().default(null);
|
|
20
20
|
const TokenCountPayloadSchema = z.object({
|
|
21
21
|
type: z.literal('token_count'),
|
|
22
22
|
info: TokenCountInfoSchema,
|
|
23
23
|
});
|
|
24
|
-
function
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
function subtractTokenUsage(current, previous) {
|
|
25
|
+
return {
|
|
26
|
+
timestamp: '',
|
|
27
|
+
inputTokens: Math.max(0, current.input_tokens - (previous?.input_tokens ?? 0)),
|
|
28
|
+
cachedInputTokens: Math.max(0, current.cached_input_tokens - (previous?.cached_input_tokens ?? 0)),
|
|
29
|
+
outputTokens: Math.max(0, current.output_tokens - (previous?.output_tokens ?? 0)),
|
|
30
|
+
reasoningOutputTokens: Math.max(0, current.reasoning_output_tokens - (previous?.reasoning_output_tokens ?? 0)),
|
|
31
|
+
totalTokens: Math.max(0, current.total_tokens - (previous?.total_tokens ?? 0)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function displayInputTokens(inputTokens, cachedInputTokens) {
|
|
35
|
+
return Math.max(0, inputTokens - cachedInputTokens);
|
|
32
36
|
}
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
// Helpers
|
|
@@ -101,7 +105,9 @@ export function parseCodexSession(filepath) {
|
|
|
101
105
|
let model = '';
|
|
102
106
|
let createdAt = '';
|
|
103
107
|
const tokenEvents = [];
|
|
108
|
+
let previousTotalUsage = null;
|
|
104
109
|
const seenTotalUsageSnapshots = new Set();
|
|
110
|
+
const seenUsageEvents = new Set();
|
|
105
111
|
for (const line of lines) {
|
|
106
112
|
const trimmed = line.trim();
|
|
107
113
|
if (!trimmed)
|
|
@@ -139,19 +145,43 @@ export function parseCodexSession(filepath) {
|
|
|
139
145
|
const info = parseResult.data.info;
|
|
140
146
|
if (!info)
|
|
141
147
|
continue;
|
|
142
|
-
const totalUsageKey =
|
|
148
|
+
const totalUsageKey = [
|
|
149
|
+
info.total_token_usage.input_tokens,
|
|
150
|
+
info.total_token_usage.cached_input_tokens,
|
|
151
|
+
info.total_token_usage.output_tokens,
|
|
152
|
+
info.total_token_usage.reasoning_output_tokens,
|
|
153
|
+
info.total_token_usage.total_tokens,
|
|
154
|
+
].join(':');
|
|
143
155
|
if (seenTotalUsageSnapshots.has(totalUsageKey))
|
|
144
156
|
continue;
|
|
145
157
|
seenTotalUsageSnapshots.add(totalUsageKey);
|
|
146
|
-
const last = info.last_token_usage;
|
|
147
|
-
|
|
158
|
+
const last = info.last_token_usage ?? info.total_token_usage;
|
|
159
|
+
const rawEvent = info.last_token_usage
|
|
160
|
+
? subtractTokenUsage(last, null)
|
|
161
|
+
: subtractTokenUsage(last, previousTotalUsage);
|
|
162
|
+
previousTotalUsage = info.total_token_usage;
|
|
163
|
+
if (rawEvent.inputTokens === 0 && rawEvent.cachedInputTokens === 0 && rawEvent.outputTokens === 0 && rawEvent.reasoningOutputTokens === 0) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const event = {
|
|
167
|
+
...rawEvent,
|
|
168
|
+
timestamp,
|
|
169
|
+
cachedInputTokens: Math.min(rawEvent.cachedInputTokens, rawEvent.inputTokens),
|
|
170
|
+
};
|
|
171
|
+
const eventKey = [
|
|
148
172
|
timestamp,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
model,
|
|
174
|
+
event.inputTokens,
|
|
175
|
+
event.cachedInputTokens,
|
|
176
|
+
event.outputTokens,
|
|
177
|
+
event.reasoningOutputTokens,
|
|
178
|
+
event.totalTokens,
|
|
179
|
+
].join(':');
|
|
180
|
+
if (seenUsageEvents.has(eventKey)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
seenUsageEvents.add(eventKey);
|
|
184
|
+
tokenEvents.push(event);
|
|
155
185
|
}
|
|
156
186
|
}
|
|
157
187
|
}
|
|
@@ -212,6 +242,12 @@ function addAcc(a, ev) {
|
|
|
212
242
|
a.reasoningOutputTokens += ev.reasoningOutputTokens;
|
|
213
243
|
a.totalTokens += ev.totalTokens;
|
|
214
244
|
}
|
|
245
|
+
function displayAcc(acc) {
|
|
246
|
+
return {
|
|
247
|
+
...acc,
|
|
248
|
+
inputTokens: displayInputTokens(acc.inputTokens, acc.cachedInputTokens),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
215
251
|
function mergeAcc(a, b) {
|
|
216
252
|
a.inputTokens += b.inputTokens;
|
|
217
253
|
a.cachedInputTokens += b.cachedInputTokens;
|
|
@@ -228,30 +264,34 @@ function addAccToBucket(bucket, ev, model) {
|
|
|
228
264
|
addAcc(bucket.models.get(model), ev);
|
|
229
265
|
}
|
|
230
266
|
function accToEntry(date, acc, modelAccs) {
|
|
267
|
+
const display = displayAcc(acc);
|
|
231
268
|
const modelNames = [...modelAccs.keys()];
|
|
232
269
|
const modelBreakdowns = buildModelBreakdowns(modelAccs);
|
|
233
270
|
const totalCost = modelBreakdowns.reduce((sum, model) => sum + model.cost, 0);
|
|
234
271
|
return {
|
|
235
272
|
date,
|
|
236
|
-
inputTokens:
|
|
237
|
-
outputTokens:
|
|
273
|
+
inputTokens: display.inputTokens,
|
|
274
|
+
outputTokens: display.outputTokens,
|
|
238
275
|
cacheCreationTokens: 0,
|
|
239
|
-
cacheReadTokens:
|
|
240
|
-
totalTokens:
|
|
276
|
+
cacheReadTokens: display.cachedInputTokens,
|
|
277
|
+
totalTokens: display.totalTokens,
|
|
241
278
|
totalCost,
|
|
242
279
|
modelsUsed: modelNames,
|
|
243
280
|
modelBreakdowns,
|
|
244
281
|
};
|
|
245
282
|
}
|
|
246
283
|
function buildModelBreakdowns(modelAccs) {
|
|
247
|
-
return [...modelAccs.entries()].map(([modelName, acc]) =>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
284
|
+
return [...modelAccs.entries()].map(([modelName, acc]) => {
|
|
285
|
+
const display = displayAcc(acc);
|
|
286
|
+
return {
|
|
287
|
+
modelName,
|
|
288
|
+
inputTokens: display.inputTokens,
|
|
289
|
+
outputTokens: display.outputTokens,
|
|
290
|
+
cacheCreationTokens: 0,
|
|
291
|
+
cacheReadTokens: display.cachedInputTokens,
|
|
292
|
+
cost: calculateCost(acc, new Set([modelName])),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
255
295
|
}
|
|
256
296
|
function groupSessions(sessions, options) {
|
|
257
297
|
const tz = options.timezone || 'Asia/Shanghai';
|
|
@@ -320,7 +360,7 @@ function buildDailyResponse(sessions, options) {
|
|
|
320
360
|
return {
|
|
321
361
|
daily,
|
|
322
362
|
totals: {
|
|
323
|
-
inputTokens: totalsAcc.inputTokens,
|
|
363
|
+
inputTokens: displayInputTokens(totalsAcc.inputTokens, totalsAcc.cachedInputTokens),
|
|
324
364
|
outputTokens: totalsAcc.outputTokens,
|
|
325
365
|
cacheCreationTokens: 0,
|
|
326
366
|
cacheReadTokens: totalsAcc.cachedInputTokens,
|
|
@@ -377,7 +417,7 @@ function buildBlocksResponse(sessions, options) {
|
|
|
377
417
|
isGap: false,
|
|
378
418
|
entries: acc.totalTokens > 0 ? 1 : 0,
|
|
379
419
|
tokenCounts: {
|
|
380
|
-
inputTokens: acc.inputTokens,
|
|
420
|
+
inputTokens: displayInputTokens(acc.inputTokens, acc.cachedInputTokens),
|
|
381
421
|
outputTokens: acc.outputTokens,
|
|
382
422
|
cacheCreationInputTokens: 0,
|
|
383
423
|
cacheReadInputTokens: acc.cachedInputTokens,
|
package/dist/server/index.js
CHANGED
|
@@ -12,11 +12,22 @@ const CLI_USAGE = [
|
|
|
12
12
|
' tokendash --port <number> [--no-open]',
|
|
13
13
|
' tokendash --tray [--port <number>]',
|
|
14
14
|
].join('\n');
|
|
15
|
+
const PACKAGE_NAME = '@zhangferry-dev/tokendash';
|
|
15
16
|
function getPackageVersion() {
|
|
16
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
18
|
const __dirname = dirname(__filename);
|
|
18
|
-
const
|
|
19
|
-
|
|
19
|
+
const packageJsonPaths = [
|
|
20
|
+
join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
|
|
21
|
+
join(__dirname, '..', 'package.json'), // dist/electron-server.cjs
|
|
22
|
+
];
|
|
23
|
+
for (const packageJsonPath of packageJsonPaths) {
|
|
24
|
+
if (!existsSync(packageJsonPath))
|
|
25
|
+
continue;
|
|
26
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
27
|
+
if (packageJson.version)
|
|
28
|
+
return packageJson.version;
|
|
29
|
+
}
|
|
30
|
+
return 'unknown';
|
|
20
31
|
}
|
|
21
32
|
function exitWithCliError(message) {
|
|
22
33
|
console.error(message);
|
|
@@ -137,7 +148,11 @@ export function createApp(_port, baseDir) {
|
|
|
137
148
|
const app = express();
|
|
138
149
|
const router = express.Router();
|
|
139
150
|
// Register API routes
|
|
140
|
-
registerApiRoutes(router
|
|
151
|
+
registerApiRoutes(router, {
|
|
152
|
+
packageName: PACKAGE_NAME,
|
|
153
|
+
version: getPackageVersion(),
|
|
154
|
+
dashboardUrl: `http://localhost:${resolvePort(_port)}`,
|
|
155
|
+
});
|
|
141
156
|
app.use('/api', router);
|
|
142
157
|
const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
|
|
143
158
|
const popoverPath = isProduction
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { type Router } from 'express';
|
|
2
|
-
export
|
|
2
|
+
export interface AppInfo {
|
|
3
|
+
packageName: string;
|
|
4
|
+
version: string;
|
|
5
|
+
dashboardUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function registerApiRoutes(router: Router, appInfo: AppInfo): void;
|
|
@@ -26,7 +26,17 @@ function getAgents(_req, res) {
|
|
|
26
26
|
res.status(500).json({ error: 'Failed to detect agents', hint: message });
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
function getAppInfo(info) {
|
|
30
|
+
return (req, res) => {
|
|
31
|
+
const host = req.get('host');
|
|
32
|
+
res.json({
|
|
33
|
+
...info,
|
|
34
|
+
dashboardUrl: host ? `${req.protocol}://${host}` : info.dashboardUrl,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function registerApiRoutes(router, appInfo) {
|
|
39
|
+
router.get('/app-info', getAppInfo(appInfo));
|
|
30
40
|
router.get('/agents', getAgents);
|
|
31
41
|
router.get('/daily', getDaily);
|
|
32
42
|
router.get('/monthly', getMonthly);
|
package/electron/main.cjs
CHANGED
|
@@ -2,7 +2,6 @@ const { app, BrowserWindow, ipcMain, screen, shell } = require('electron');
|
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const http = require('node:http');
|
|
5
|
-
const https = require('node:https');
|
|
6
5
|
const { spawn } = require('node:child_process');
|
|
7
6
|
|
|
8
7
|
// Global debug logger (writes to file since stdout is lost in packaged apps)
|
|
@@ -19,6 +18,9 @@ try {
|
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
const { formatTokens } = require('./trayBadge.cjs');
|
|
21
|
+
const { checkForUpdates, downloadUpdateAsset } = require('./updateService.cjs');
|
|
22
|
+
const { syncNpmPackageVersion } = require('./npmSync.cjs');
|
|
23
|
+
const { findCompatibleServer, getDashboardUrl } = require('./serverReuse.cjs');
|
|
22
24
|
|
|
23
25
|
// Resolve trayHelper binary: extract from asar if needed
|
|
24
26
|
function resolveTrayHelperPath() {
|
|
@@ -52,6 +54,9 @@ let server = null;
|
|
|
52
54
|
let trayProcess = null;
|
|
53
55
|
let selectedAgents = null; // null = use all available agents
|
|
54
56
|
let serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
|
|
57
|
+
let dashboardUrl = getDashboardUrl(serverPort);
|
|
58
|
+
let lastUpdateInfo = null;
|
|
59
|
+
let isDownloadingUpdate = false;
|
|
55
60
|
const POPOVER_WIDTH = 380;
|
|
56
61
|
const POPOVER_HEIGHT = 540;
|
|
57
62
|
const PACKAGE_NAME = '@zhangferry-dev/tokendash';
|
|
@@ -97,39 +102,8 @@ function fetchJson(url) {
|
|
|
97
102
|
});
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
function
|
|
101
|
-
return
|
|
102
|
-
const opts = new URL(url);
|
|
103
|
-
const reqOpts = {
|
|
104
|
-
hostname: opts.hostname,
|
|
105
|
-
path: opts.pathname + opts.search,
|
|
106
|
-
method: 'GET',
|
|
107
|
-
headers: { 'User-Agent': 'TokenDash' },
|
|
108
|
-
};
|
|
109
|
-
https.get(reqOpts, (res) => {
|
|
110
|
-
let data = '';
|
|
111
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
112
|
-
res.on('end', () => {
|
|
113
|
-
if (res.statusCode && res.statusCode >= 400) {
|
|
114
|
-
reject(new Error(`HTTP ${res.statusCode}`));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
try { resolve(JSON.parse(data)); }
|
|
118
|
-
catch (e) { reject(e); }
|
|
119
|
-
});
|
|
120
|
-
}).on('error', reject);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function compareVersions(a, b) {
|
|
125
|
-
const aParts = String(a).split('.').map((part) => parseInt(part, 10) || 0);
|
|
126
|
-
const bParts = String(b).split('.').map((part) => parseInt(part, 10) || 0);
|
|
127
|
-
const maxLen = Math.max(aParts.length, bParts.length);
|
|
128
|
-
for (let i = 0; i < maxLen; i++) {
|
|
129
|
-
const delta = (aParts[i] || 0) - (bParts[i] || 0);
|
|
130
|
-
if (delta !== 0) return delta;
|
|
131
|
-
}
|
|
132
|
-
return 0;
|
|
105
|
+
function getServerBaseUrl() {
|
|
106
|
+
return dashboardUrl || getDashboardUrl(serverPort);
|
|
133
107
|
}
|
|
134
108
|
|
|
135
109
|
function getAppInfo() {
|
|
@@ -258,17 +232,19 @@ function getTrayAgentKey(agents) {
|
|
|
258
232
|
|
|
259
233
|
function applyTraySnapshot(snapshot) {
|
|
260
234
|
const totalTokens = Number(snapshot && snapshot.totalTokens) || 0;
|
|
235
|
+
const totalInput = Number(snapshot && snapshot.totalInput) || 0;
|
|
261
236
|
const totalCost = Number(snapshot && snapshot.totalCost) || 0;
|
|
262
237
|
const totalCacheRead = Number(snapshot && snapshot.totalCacheRead) || 0;
|
|
263
238
|
const today = snapshot && snapshot.today;
|
|
264
239
|
const agentKey = snapshot && snapshot.agentKey;
|
|
265
240
|
|
|
266
|
-
lastTraySnapshot = { today, agentKey, totalTokens, totalCost, totalCacheRead };
|
|
241
|
+
lastTraySnapshot = { today, agentKey, totalTokens, totalInput, totalCost, totalCacheRead };
|
|
267
242
|
|
|
268
243
|
const tokenStr = formatTokens(totalTokens);
|
|
269
244
|
sendTrayCommand('title:' + tokenStr);
|
|
270
245
|
|
|
271
|
-
const
|
|
246
|
+
const cacheInput = totalInput + totalCacheRead;
|
|
247
|
+
const cacheRate = cacheInput > 0 ? ((totalCacheRead / cacheInput) * 100).toFixed(1) : '0.0';
|
|
272
248
|
sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
|
|
273
249
|
}
|
|
274
250
|
|
|
@@ -276,7 +252,8 @@ function updateTrayBadge() {
|
|
|
276
252
|
const d = new Date(); const today = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
|
|
277
253
|
|
|
278
254
|
// Fetch agents list, then fetch daily data for each agent in parallel
|
|
279
|
-
|
|
255
|
+
const serverBaseUrl = getServerBaseUrl();
|
|
256
|
+
fetchJson(`${serverBaseUrl}/api/agents`)
|
|
280
257
|
.then((agentData) => {
|
|
281
258
|
let agents = (agentData && Array.isArray(agentData.available)) ? agentData.available : ['claude'];
|
|
282
259
|
if (agents.length === 0) {
|
|
@@ -293,7 +270,7 @@ function updateTrayBadge() {
|
|
|
293
270
|
const agentKey = getTrayAgentKey(agents);
|
|
294
271
|
return Promise.all(
|
|
295
272
|
agents.map(agent =>
|
|
296
|
-
fetchJson(
|
|
273
|
+
fetchJson(`${serverBaseUrl}/api/daily?agent=${agent}`)
|
|
297
274
|
.catch(() => null)
|
|
298
275
|
)
|
|
299
276
|
).then(results => ({ agentKey, results }));
|
|
@@ -337,7 +314,7 @@ function updateTrayBadge() {
|
|
|
337
314
|
return;
|
|
338
315
|
}
|
|
339
316
|
|
|
340
|
-
applyTraySnapshot({ today, agentKey, totalTokens, totalCost, totalCacheRead });
|
|
317
|
+
applyTraySnapshot({ today, agentKey, totalTokens, totalInput, totalCost, totalCacheRead });
|
|
341
318
|
})
|
|
342
319
|
.catch((err) => {
|
|
343
320
|
if (err.code !== 'ECONNREFUSED') {
|
|
@@ -381,7 +358,7 @@ function createPopoverWindow() {
|
|
|
381
358
|
},
|
|
382
359
|
});
|
|
383
360
|
|
|
384
|
-
popover.loadURL(
|
|
361
|
+
popover.loadURL(`${getServerBaseUrl()}/popover.html`);
|
|
385
362
|
|
|
386
363
|
popover.on('blur', () => {
|
|
387
364
|
popover.hide();
|
|
@@ -397,8 +374,7 @@ function createPopoverWindow() {
|
|
|
397
374
|
|
|
398
375
|
function registerIpcHandlers() {
|
|
399
376
|
ipcMain.handle('tokendash:open-dashboard', (_event, url) => {
|
|
400
|
-
|
|
401
|
-
return shell.openExternal(target);
|
|
377
|
+
return shell.openExternal(getServerBaseUrl());
|
|
402
378
|
});
|
|
403
379
|
|
|
404
380
|
ipcMain.handle('tokendash:get-app-info', () => {
|
|
@@ -413,19 +389,10 @@ function registerIpcHandlers() {
|
|
|
413
389
|
|
|
414
390
|
ipcMain.handle('tokendash:check-for-updates', async () => {
|
|
415
391
|
const currentVersion = getAppInfo().version;
|
|
416
|
-
const releasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
|
417
392
|
|
|
418
393
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const tag = (latest.tag_name || '').replace(/^v/, '');
|
|
422
|
-
const latestVersion = tag || currentVersion;
|
|
423
|
-
return {
|
|
424
|
-
currentVersion,
|
|
425
|
-
latestVersion,
|
|
426
|
-
upToDate: compareVersions(currentVersion, latestVersion) >= 0,
|
|
427
|
-
releaseUrl: latest.html_url || null,
|
|
428
|
-
};
|
|
394
|
+
lastUpdateInfo = await checkForUpdates({ repo: GITHUB_REPO, currentVersion });
|
|
395
|
+
return lastUpdateInfo;
|
|
429
396
|
} catch (error) {
|
|
430
397
|
return {
|
|
431
398
|
currentVersion,
|
|
@@ -436,6 +403,31 @@ function registerIpcHandlers() {
|
|
|
436
403
|
}
|
|
437
404
|
});
|
|
438
405
|
|
|
406
|
+
ipcMain.handle('tokendash:download-update', async (event) => {
|
|
407
|
+
if (isDownloadingUpdate) {
|
|
408
|
+
return { ok: false, error: 'An update download is already in progress.' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const info = lastUpdateInfo;
|
|
412
|
+
if (!info || info.upToDate || !info.asset || !info.asset.url) {
|
|
413
|
+
return { ok: false, error: 'No downloadable update is available.' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
isDownloadingUpdate = true;
|
|
417
|
+
try {
|
|
418
|
+
const downloadsDir = path.join(app.getPath('downloads'), 'TokenDash Updates');
|
|
419
|
+
const filePath = await downloadUpdateAsset(info.asset, downloadsDir, (progress) => {
|
|
420
|
+
event.sender.send('tokendash:update-download-progress', progress);
|
|
421
|
+
});
|
|
422
|
+
await shell.openPath(filePath);
|
|
423
|
+
return { ok: true, filePath };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
426
|
+
} finally {
|
|
427
|
+
isDownloadingUpdate = false;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
439
431
|
ipcMain.handle('tokendash:quit', () => {
|
|
440
432
|
app.isQuitting = true;
|
|
441
433
|
stopBadgeUpdates();
|
|
@@ -477,15 +469,34 @@ app.whenReady().then(async () => {
|
|
|
477
469
|
if (server) server.close();
|
|
478
470
|
});
|
|
479
471
|
|
|
480
|
-
|
|
481
|
-
|
|
472
|
+
const currentVersion = getAppInfo().version;
|
|
473
|
+
syncNpmPackageVersion(PACKAGE_NAME, currentVersion).then((result) => {
|
|
474
|
+
if (!result || result.ok) return;
|
|
475
|
+
console.warn('Could not sync npm package version:', result.error || 'unknown error');
|
|
476
|
+
}).catch((error) => {
|
|
477
|
+
console.warn('Could not sync npm package version:', error instanceof Error ? error.message : String(error));
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const existingServer = await findCompatibleServer(serverPort, currentVersion, PACKAGE_NAME);
|
|
481
|
+
if (existingServer) {
|
|
482
|
+
serverPort = existingServer.port;
|
|
483
|
+
dashboardUrl = existingServer.dashboardUrl;
|
|
484
|
+
console.log(`tokendash reusing CLI server on ${dashboardUrl}`);
|
|
485
|
+
startTrayHelper();
|
|
486
|
+
createPopoverWindow();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Create and bind Express server.
|
|
491
|
+
// Pass dist/ directory so createApp resolves client assets correctly.
|
|
482
492
|
const distDir = path.join(__dirname, '..', 'dist');
|
|
483
493
|
const expressApp = createApp(serverPort, distDir);
|
|
484
494
|
try {
|
|
485
495
|
const result = await listenWithFallback(expressApp, serverPort);
|
|
486
496
|
server = result.server;
|
|
487
497
|
serverPort = result.port;
|
|
488
|
-
|
|
498
|
+
dashboardUrl = getDashboardUrl(result.port);
|
|
499
|
+
console.log(`tokendash running on ${dashboardUrl}`);
|
|
489
500
|
} catch (err) {
|
|
490
501
|
console.error('Failed to start server:', err);
|
|
491
502
|
app.quit();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { spawn } = require('node:child_process');
|
|
2
|
+
|
|
3
|
+
function normalizeVersion(version) {
|
|
4
|
+
return String(version || '').trim().replace(/^v/, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function shouldInstallPackage(installedVersion, targetVersion) {
|
|
8
|
+
const installed = normalizeVersion(installedVersion);
|
|
9
|
+
const target = normalizeVersion(targetVersion);
|
|
10
|
+
return Boolean(target) && installed !== target;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildNpmInstallArgs(packageName, version) {
|
|
14
|
+
return ['install', '-g', `${packageName}@${version}`];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runCommand(command, args) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
|
|
23
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
24
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
25
|
+
child.on('error', (error) => resolve({ ok: false, stdout, stderr, error }));
|
|
26
|
+
child.on('close', (code) => resolve({ ok: code === 0, code, stdout, stderr }));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getInstalledPackageVersion(packageName) {
|
|
31
|
+
const result = await runCommand('npm', ['list', '-g', packageName, '--depth=0', '--json']);
|
|
32
|
+
if (!result.ok) return null;
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(result.stdout);
|
|
35
|
+
return normalizeVersion(data && data.dependencies && data.dependencies[packageName] && data.dependencies[packageName].version);
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function syncNpmPackageVersion(packageName, version) {
|
|
42
|
+
const installedVersion = await getInstalledPackageVersion(packageName);
|
|
43
|
+
if (!shouldInstallPackage(installedVersion, version)) {
|
|
44
|
+
return { ok: true, installedVersion, targetVersion: version, changed: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await runCommand('npm', buildNpmInstallArgs(packageName, version));
|
|
48
|
+
return {
|
|
49
|
+
ok: result.ok,
|
|
50
|
+
installedVersion,
|
|
51
|
+
targetVersion: version,
|
|
52
|
+
changed: result.ok,
|
|
53
|
+
error: result.ok ? null : (result.error ? result.error.message : result.stderr || `npm exited with ${result.code}`),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
buildNpmInstallArgs,
|
|
59
|
+
getInstalledPackageVersion,
|
|
60
|
+
shouldInstallPackage,
|
|
61
|
+
syncNpmPackageVersion,
|
|
62
|
+
};
|