@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.
@@ -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
- const nonCachedInput = Math.max(inputTokens - cacheReadTokens, 0);
32
- return (nonCachedInput / 1_000_000) * p.inputPer1M
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 + outputTokens + cacheReadTokens;
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 + e.outputTokens + e.cacheReadTokens;
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 + e.outputTokens + e.cacheReadTokens;
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 + bucket.outputTokens + bucket.cacheReadTokens;
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 tokenUsageKey(usage) {
25
- return [
26
- usage.input_tokens,
27
- usage.cached_input_tokens,
28
- usage.output_tokens,
29
- usage.reasoning_output_tokens,
30
- usage.total_tokens,
31
- ].join(':');
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 = tokenUsageKey(info.total_token_usage);
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
- tokenEvents.push({
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
- inputTokens: last.input_tokens,
150
- cachedInputTokens: last.cached_input_tokens,
151
- outputTokens: last.output_tokens,
152
- reasoningOutputTokens: last.reasoning_output_tokens,
153
- totalTokens: last.total_tokens,
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: acc.inputTokens,
237
- outputTokens: acc.outputTokens,
273
+ inputTokens: display.inputTokens,
274
+ outputTokens: display.outputTokens,
238
275
  cacheCreationTokens: 0,
239
- cacheReadTokens: acc.cachedInputTokens,
240
- totalTokens: acc.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
- modelName,
249
- inputTokens: acc.inputTokens,
250
- outputTokens: acc.outputTokens,
251
- cacheCreationTokens: 0,
252
- cacheReadTokens: acc.cachedInputTokens,
253
- cost: calculateCost(acc, new Set([modelName])),
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,
@@ -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 packageJson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
19
- return packageJson.version ?? 'unknown';
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 declare function registerApiRoutes(router: Router): void;
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
- export function registerApiRoutes(router) {
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 fetchHttpsJson(url) {
101
- return new Promise((resolve, reject) => {
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 cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
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
- fetchJson(`http://localhost:${serverPort}/api/agents`)
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(`http://localhost:${serverPort}/api/daily?agent=${agent}`)
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(`http://localhost:${serverPort}/popover.html`);
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
- const target = typeof url === 'string' && url.length > 0 ? url : `http://localhost:${serverPort}`;
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
- const latest = await fetchHttpsJson(releasesUrl);
420
- // GitHub release tag may be "v1.3.0" or "1.3.0"
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
- // Create and bind Express server
481
- // Pass dist/ directory so createApp resolves client assets correctly
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
- console.log(`tokendash running on http://localhost:${result.port}`);
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
+ };