@zhangferry-dev/tokendash 1.4.0 → 1.4.2

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.
@@ -29,11 +29,10 @@ export declare function scanCodexSessions(): string[];
29
29
  /**
30
30
  * Parse a single Codex session JSONL file.
31
31
  *
32
- * CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
33
- * Each Codex turn emits TWO token_count events with identical last_token_usage
34
- * values (~1-2s apart) one for reasoning, one for output completion.
35
- * Both are distinct billable events. Deduplicating would produce the wrong
36
- * total (4.7M instead of the correct 9.2M).
32
+ * Codex can emit duplicate token_count events for the same turn, with identical
33
+ * total_token_usage and last_token_usage snapshots a few seconds apart. These
34
+ * are repeated status updates, not separate billable usage records, so only the
35
+ * first occurrence of each cumulative total_token_usage snapshot should count.
37
36
  */
38
37
  export declare function parseCodexSession(filepath: string): ParsedSession | null;
39
38
  /** Parse all Codex sessions. */
@@ -21,6 +21,15 @@ 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(':');
32
+ }
24
33
  // ---------------------------------------------------------------------------
25
34
  // Helpers
26
35
  // ---------------------------------------------------------------------------
@@ -73,11 +82,10 @@ export function scanCodexSessions() {
73
82
  /**
74
83
  * Parse a single Codex session JSONL file.
75
84
  *
76
- * CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
77
- * Each Codex turn emits TWO token_count events with identical last_token_usage
78
- * values (~1-2s apart) one for reasoning, one for output completion.
79
- * Both are distinct billable events. Deduplicating would produce the wrong
80
- * total (4.7M instead of the correct 9.2M).
85
+ * Codex can emit duplicate token_count events for the same turn, with identical
86
+ * total_token_usage and last_token_usage snapshots a few seconds apart. These
87
+ * are repeated status updates, not separate billable usage records, so only the
88
+ * first occurrence of each cumulative total_token_usage snapshot should count.
81
89
  */
82
90
  export function parseCodexSession(filepath) {
83
91
  let content;
@@ -93,6 +101,7 @@ export function parseCodexSession(filepath) {
93
101
  let model = '';
94
102
  let createdAt = '';
95
103
  const tokenEvents = [];
104
+ const seenTotalUsageSnapshots = new Set();
96
105
  for (const line of lines) {
97
106
  const trimmed = line.trim();
98
107
  if (!trimmed)
@@ -118,7 +127,6 @@ export function parseCodexSession(filepath) {
118
127
  }
119
128
  }
120
129
  // Extract token counts from event_msg with nested token_count payload.
121
- // NEVER deduplicate — see invariant comment above.
122
130
  if (type === 'event_msg') {
123
131
  const payload = obj.payload || {};
124
132
  if (payload.type === 'token_count') {
@@ -131,6 +139,10 @@ export function parseCodexSession(filepath) {
131
139
  const info = parseResult.data.info;
132
140
  if (!info)
133
141
  continue;
142
+ const totalUsageKey = tokenUsageKey(info.total_token_usage);
143
+ if (seenTotalUsageSnapshots.has(totalUsageKey))
144
+ continue;
145
+ seenTotalUsageSnapshots.add(totalUsageKey);
134
146
  const last = info.last_token_usage;
135
147
  tokenEvents.push({
136
148
  timestamp,
package/electron/main.cjs CHANGED
@@ -51,7 +51,7 @@ let popover = null;
51
51
  let server = null;
52
52
  let trayProcess = null;
53
53
  let selectedAgents = null; // null = use all available agents
54
- const serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
54
+ let serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
55
55
  const POPOVER_WIDTH = 380;
56
56
  const POPOVER_HEIGHT = 540;
57
57
  const PACKAGE_NAME = '@zhangferry-dev/tokendash';
@@ -250,6 +250,27 @@ function stopTrayHelper() {
250
250
  // ---------------------------------------------------------------------------
251
251
 
252
252
  let updateTimer = null;
253
+ let lastTraySnapshot = null;
254
+
255
+ function getTrayAgentKey(agents) {
256
+ return agents.slice().sort().join(',');
257
+ }
258
+
259
+ function applyTraySnapshot(snapshot) {
260
+ const totalTokens = Number(snapshot && snapshot.totalTokens) || 0;
261
+ const totalCost = Number(snapshot && snapshot.totalCost) || 0;
262
+ const totalCacheRead = Number(snapshot && snapshot.totalCacheRead) || 0;
263
+ const today = snapshot && snapshot.today;
264
+ const agentKey = snapshot && snapshot.agentKey;
265
+
266
+ lastTraySnapshot = { today, agentKey, totalTokens, totalCost, totalCacheRead };
267
+
268
+ const tokenStr = formatTokens(totalTokens);
269
+ sendTrayCommand('title:' + tokenStr);
270
+
271
+ const cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
272
+ sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
273
+ }
253
274
 
254
275
  function updateTrayBadge() {
255
276
  const d = new Date(); const today = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
@@ -257,20 +278,35 @@ function updateTrayBadge() {
257
278
  // Fetch agents list, then fetch daily data for each agent in parallel
258
279
  fetchJson(`http://localhost:${serverPort}/api/agents`)
259
280
  .then((agentData) => {
260
- let agents = (agentData && agentData.available) ? agentData.available : ['claude'];
281
+ let agents = (agentData && Array.isArray(agentData.available)) ? agentData.available : ['claude'];
282
+ if (agents.length === 0) {
283
+ // Transient agent detection failures should not clear a previously valid tray badge.
284
+ return null;
285
+ }
286
+
261
287
  // Apply agent filter from popover settings
262
288
  if (selectedAgents && selectedAgents.length > 0) {
263
- agents = agents.filter(a => selectedAgents.includes(a));
264
- if (agents.length === 0) agents = (agentData && agentData.available) ? agentData.available : ['claude'];
289
+ const filtered = agents.filter(a => selectedAgents.includes(a));
290
+ if (filtered.length > 0) agents = filtered;
265
291
  }
292
+
293
+ const agentKey = getTrayAgentKey(agents);
266
294
  return Promise.all(
267
295
  agents.map(agent =>
268
296
  fetchJson(`http://localhost:${serverPort}/api/daily?agent=${agent}`)
269
297
  .catch(() => null)
270
298
  )
271
- );
299
+ ).then(results => ({ agentKey, results }));
272
300
  })
273
- .then((results) => {
301
+ .then((payload) => {
302
+ if (!payload) return;
303
+ const { agentKey, results } = payload;
304
+ const successfulResults = results.filter(data => data && data.daily);
305
+ if (successfulResults.length === 0) {
306
+ // Keep the last good value when every daily request failed.
307
+ return;
308
+ }
309
+
274
310
  let totalTokens = 0;
275
311
  let totalCost = 0;
276
312
  let totalInput = 0;
@@ -288,13 +324,20 @@ function updateTrayBadge() {
288
324
  totalCacheRead += entry.cacheReadTokens || 0;
289
325
  }
290
326
 
291
- // Show token count on tray icon
292
- const tokenStr = formatTokens(totalTokens);
293
- sendTrayCommand('title:' + tokenStr);
327
+ const shouldPreserveLastPositive =
328
+ totalTokens === 0 &&
329
+ lastTraySnapshot &&
330
+ lastTraySnapshot.today === today &&
331
+ lastTraySnapshot.agentKey === agentKey &&
332
+ lastTraySnapshot.totalTokens > 0;
333
+
334
+ if (shouldPreserveLastPositive) {
335
+ // Daily usage should not drop to zero during the same day for the same agent filter.
336
+ // Treat a zero refresh after a positive value as transient empty data and keep the badge stable.
337
+ return;
338
+ }
294
339
 
295
- // Tooltip with breakdown
296
- const cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
297
- sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
340
+ applyTraySnapshot({ today, agentKey, totalTokens, totalCost, totalCacheRead });
298
341
  })
299
342
  .catch((err) => {
300
343
  if (err.code !== 'ECONNREFUSED') {
@@ -403,10 +446,17 @@ function registerIpcHandlers() {
403
446
 
404
447
  ipcMain.handle('tokendash:set-selected-agents', (_event, agents) => {
405
448
  selectedAgents = Array.isArray(agents) ? agents : null;
449
+ lastTraySnapshot = null;
406
450
  // Immediately refresh badge with new filter
407
451
  updateTrayBadge();
408
452
  return { ok: true };
409
453
  });
454
+
455
+ ipcMain.handle('tokendash:update-tray-snapshot', (_event, snapshot) => {
456
+ if (!snapshot || typeof snapshot !== 'object') return { ok: false };
457
+ applyTraySnapshot(snapshot);
458
+ return { ok: true };
459
+ });
410
460
  }
411
461
 
412
462
  // ---------------------------------------------------------------------------
@@ -414,6 +464,10 @@ function registerIpcHandlers() {
414
464
  // ---------------------------------------------------------------------------
415
465
 
416
466
  app.whenReady().then(async () => {
467
+ if (process.platform === 'darwin' && app.dock) {
468
+ app.dock.hide();
469
+ }
470
+
417
471
  registerIpcHandlers();
418
472
 
419
473
  app.on('before-quit', () => {
@@ -430,6 +484,7 @@ app.whenReady().then(async () => {
430
484
  try {
431
485
  const result = await listenWithFallback(expressApp, serverPort);
432
486
  server = result.server;
487
+ serverPort = result.port;
433
488
  console.log(`tokendash running on http://localhost:${result.port}`);
434
489
  } catch (err) {
435
490
  console.error('Failed to start server:', err);
@@ -19,4 +19,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
19
19
  setSelectedAgents(agents) {
20
20
  return ipcRenderer.invoke('tokendash:set-selected-agents', agents);
21
21
  },
22
+ updateTraySnapshot(snapshot) {
23
+ return ipcRenderer.invoke('tokendash:update-tray-snapshot', snapshot);
24
+ },
22
25
  });
@@ -5,10 +5,12 @@
5
5
  * Examples: 1234 -> "1.2K", 567890 -> "567.9K", 1500000 -> "1.5M"
6
6
  */
7
7
  function formatTokens(tokens) {
8
+ tokens = Number(tokens) || 0;
9
+
8
10
  if (tokens >= 1e6) return (tokens / 1e6).toFixed(1) + 'M';
9
11
  if (tokens >= 1e3) return (tokens / 1e3).toFixed(1) + 'K';
10
12
  if (tokens > 0) return String(tokens);
11
- return '0.0M';
13
+ return '0';
12
14
  }
13
15
 
14
16
  /**
Binary file
@@ -9,18 +9,17 @@ import Cocoa
9
9
  class AppDelegate: NSObject, NSApplicationDelegate {
10
10
  var statusItem: NSStatusItem!
11
11
  var readHandle: FileHandle?
12
+ var currentTitle = "0"
12
13
 
13
14
  func applicationDidFinishLaunching(_ notification: Notification) {
14
15
  // Create status bar item
15
16
  statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
16
17
 
17
- // Add a small template icon (filled circle) before the title
18
- let icon = createTemplateIcon(size: NSSize(width: 18, height: 18))
19
- statusItem.button?.image = icon
20
- statusItem.button?.imagePosition = .imageLeft
18
+ statusItem.button?.image = renderCombinedImage(title: currentTitle)
19
+ statusItem.button?.imagePosition = .imageOnly
21
20
  statusItem.button?.imageScaling = .scaleProportionallyDown
22
-
23
- statusItem.button?.title = "0.0M"
21
+ statusItem.button?.isBordered = false
22
+ statusItem.button?.title = ""
24
23
  statusItem.button?.toolTip = "TokenDash"
25
24
 
26
25
  // Set up click actions — both left and right click
@@ -42,9 +41,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
42
41
  sendEvent("ready")
43
42
  }
44
43
 
44
+ /// Render icon + title text into a single template image for the status bar.
45
+ func renderCombinedImage(title: String) -> NSImage {
46
+ let iconW: CGFloat = 18
47
+ let iconH: CGFloat = 18
48
+ let fontSize: CGFloat = 13
49
+ let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium)
50
+ let textAttrs: [NSAttributedString.Key: Any] = [.font: font]
51
+ let textWidth = (title as NSString).size(withAttributes: textAttrs).width
52
+ let padding: CGFloat = 4 // gap between icon and text
53
+
54
+ let totalWidth = iconW + padding + textWidth
55
+ // Status bar height is ~22pt; center vertically
56
+ let totalHeight: CGFloat = 20
57
+
58
+ let image = NSImage(size: NSSize(width: totalWidth, height: totalHeight))
59
+ image.lockFocus()
60
+
61
+ // Draw icon centered vertically
62
+ let icon = createTemplateIcon(size: NSSize(width: iconW, height: iconH))
63
+ let iconY = (totalHeight - iconH) / 2.0
64
+ icon.draw(in: NSRect(x: 0, y: iconY, width: iconW, height: iconH))
65
+
66
+ // Draw text centered vertically (baseline-adjusted)
67
+ let textY = (totalHeight - fontSize) / 2.0 - 1
68
+ (title as NSString).draw(at: NSPoint(x: iconW + padding, y: textY), withAttributes: textAttrs)
69
+
70
+ image.unlockFocus()
71
+ image.isTemplate = true
72
+ return image
73
+ }
74
+
45
75
  @objc func handleClick(_ sender: Any?) {
46
76
  guard let _ = NSApp.currentEvent else { return }
47
- // Send click with screen coordinates so Electron can position the popover
48
77
  let loc = NSEvent.mouseLocation
49
78
  sendEvent("click:\(Int(loc.x)),\(Int(loc.y))")
50
79
  }
@@ -60,7 +89,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
60
89
  let cmd = command.trimmingCharacters(in: .whitespacesAndNewlines)
61
90
  if cmd.hasPrefix("title:") {
62
91
  let title = String(cmd.dropFirst(6))
63
- statusItem.button?.title = title
92
+ currentTitle = title
93
+ statusItem.button?.image = renderCombinedImage(title: title)
64
94
  } else if cmd.hasPrefix("tooltip:") {
65
95
  let tooltip = String(cmd.dropFirst(8))
66
96
  statusItem.button?.toolTip = tooltip
@@ -83,32 +113,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
83
113
  let image = NSImage(size: size)
84
114
  image.lockFocus()
85
115
 
86
- // Draw the TokenDash wavy line icon (from SVG, 64x64 viewBox scaled to size)
87
- // SVG coords use top-left origin; AppKit uses bottom-left, so flip Y: y = (64 - svgY)
88
116
  let sx = size.width / 64.0
89
117
  let sy = size.height / 64.0
90
118
 
91
119
  let path = NSBezierPath()
92
- // M6,32 H18
93
120
  path.move(to: NSPoint(x: 6 * sx, y: (64 - 32) * sy))
94
121
  path.line(to: NSPoint(x: 18 * sx, y: (64 - 32) * sy))
95
- // C21,32 22.5,34 24.5,39
96
122
  path.curve(to: NSPoint(x: 24.5 * sx, y: (64 - 39) * sy),
97
123
  controlPoint1: NSPoint(x: 21 * sx, y: (64 - 32) * sy),
98
124
  controlPoint2: NSPoint(x: 22.5 * sx, y: (64 - 34) * sy))
99
- // C27,45.5 30,50 34,50
100
125
  path.curve(to: NSPoint(x: 34 * sx, y: (64 - 50) * sy),
101
126
  controlPoint1: NSPoint(x: 27 * sx, y: (64 - 45.5) * sy),
102
127
  controlPoint2: NSPoint(x: 30 * sx, y: (64 - 50) * sy))
103
- // C38,50 40.5,42 44,22
104
128
  path.curve(to: NSPoint(x: 44 * sx, y: (64 - 22) * sy),
105
129
  controlPoint1: NSPoint(x: 38 * sx, y: (64 - 50) * sy),
106
130
  controlPoint2: NSPoint(x: 40.5 * sx, y: (64 - 42) * sy))
107
- // C46,11 49,8 52,8
108
131
  path.curve(to: NSPoint(x: 52 * sx, y: (64 - 8) * sy),
109
132
  controlPoint1: NSPoint(x: 46 * sx, y: (64 - 11) * sy),
110
133
  controlPoint2: NSPoint(x: 49 * sx, y: (64 - 8) * sy))
111
- // C55,8 57.5,13 60,22
112
134
  path.curve(to: NSPoint(x: 60 * sx, y: (64 - 22) * sy),
113
135
  controlPoint1: NSPoint(x: 55 * sx, y: (64 - 8) * sy),
114
136
  controlPoint2: NSPoint(x: 57.5 * sx, y: (64 - 13) * sy))
@@ -10,8 +10,11 @@ files:
10
10
  mac:
11
11
  target: dmg
12
12
  category: public.app-category.developer-tools
13
- icon: resources/icon.png
13
+ icon: resources/icon.icns
14
14
  identity: null
15
15
  darkModeSupport: true
16
+ minimumSystemVersion: '14.0'
17
+ extendInfo:
18
+ LSUIElement: true
16
19
  extraMetadata:
17
20
  main: electron/main.cjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {
Binary file
Binary file