claude-code-watch 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -208,6 +208,66 @@ body {
208
208
  .tree-row:hover .tree-actions { display: flex; }
209
209
  .tree-row.selected>.tree-actions { display: flex; }
210
210
 
211
+ /* ── Tokens page ── */
212
+ #tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 20px 24px; gap: 16px; background: var(--bg); }
213
+ .tp-top { display: flex; gap: 16px; }
214
+ .tp-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
215
+ .tp-right { flex: 1; display: flex; flex-direction: column; gap: 12px; }
216
+ .tp-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
217
+ .tp-total-label { font-size: 11px; color: var(--dim); text-transform: uppercase; margin-bottom: 2px; }
218
+ .tp-total-value { font-size: 22px; font-weight: 700; color: var(--white); font-family: monospace; }
219
+ .tp-total-sub { font-size: 10px; color: var(--dim); margin-top: 2px; }
220
+ .tp-stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
221
+ .tp-stat { padding: 8px 10px; background: var(--bg3); border-radius: 6px; }
222
+ .tp-stat .tp-s-l { font-size: 10px; color: var(--dim); }
223
+ .tp-stat .tp-s-v { font-size: 13px; font-weight: 600; color: var(--white); font-family: monospace; }
224
+ .tp-rank-title { font-size: 12px; color: var(--dim); font-weight: 600; margin-bottom: 8px; }
225
+ .tp-rank-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
226
+ .tp-rank-num { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--bg3); font-size: 10px; font-weight: 600; color: var(--dim); flex-shrink: 0; }
227
+ .tp-rank-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
228
+ .tp-rank-name { font-size: 12px; color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
229
+ .tp-rank-pct { font-size: 12px; font-weight: 600; color: var(--white); font-family: monospace; flex-shrink: 0; }
230
+ .tp-footer-stats { display: flex; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--dim); }
231
+ .tp-footer-stats .tp-fv { color: var(--text); font-family: monospace; }
232
+ .tp-h3 { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
233
+
234
+ /* ── Heatmap ── */
235
+ .tp-heatmap { overflow-x: auto; }
236
+ .tp-heatmap-inner { display: inline-flex; flex-direction: column; gap: 2px; }
237
+ .tp-hm-months { display: flex; gap: 0; font-size: 10px; color: var(--dim); margin-bottom: 2px; padding-left: 28px; }
238
+ .tp-hm-row { display: flex; align-items: center; gap: 2px; }
239
+ .tp-hm-day-label { width: 24px; font-size: 10px; color: var(--dim); text-align: right; flex-shrink: 0; }
240
+ .tp-hm-cell { width: 12px; height: 12px; border-radius: 2px; transition: transform 0.15s; cursor: pointer; position: relative; }
241
+ .tp-hm-cell:hover { transform: scale(1.6); z-index: 10; }
242
+ .tp-hm-cell[title]:hover::after { content: attr(title); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
243
+ .tp-hm-legend { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--dim); margin-top: 6px; justify-content: flex-end; }
244
+ .tp-hm-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
245
+
246
+ /* ── Trend bars ── */
247
+ .tp-trend-bars { display: flex; align-items: flex-end; gap: 3px; height: 140px; position: relative; padding-bottom: 20px; }
248
+ .tp-trend-bar-wrap { flex: 1; display: flex; flex-direction: column; position: relative; min-width: 0; }
249
+ .tp-trend-bar { position: relative; border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 4px; }
250
+ .tp-trend-bar:hover { filter: brightness(1.3); }
251
+ .tp-trend-bar:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
252
+ .tp-trend-label { font-size: 9px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; white-space: nowrap; overflow: hidden; }
253
+ .tp-trend-grid-lines { position: absolute; inset: 0 0 20px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
254
+ .tp-trend-grid-line { width: 100%; border-top: 1px dashed var(--border); opacity: 0.4; }
255
+
256
+ /* ── Detail table ── */
257
+ .tp-tabs { display: flex; gap: 2px; }
258
+ .tp-tab { padding: 5px 12px; cursor: pointer; border-radius: 6px 6px 0 0; background: var(--bg3); color: var(--dim); border: 1px solid var(--border); border-bottom: none; font-size: 12px; font-weight: 500; }
259
+ .tp-tab.active { background: var(--bg2); color: var(--white); font-weight: 600; }
260
+ .tp-tc { display: none; }
261
+ .tp-tc.active { display: block; }
262
+ .tp-st { max-height: 480px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
263
+ .tp-table { width: 100%; border-collapse: collapse; font-size: 11px; background: var(--bg2); }
264
+ .tp-table th { background: var(--bg3); color: var(--white); padding: 7px 10px; text-align: left; font-weight: 600; white-space: nowrap; }
265
+ .tp-table td { padding: 6px 10px; border-top: 1px solid var(--border); white-space: nowrap; font-family: monospace; color: var(--text); }
266
+ .tp-table tr:hover td { background: var(--bg3); }
267
+ .tp-table tfoot td { font-weight: 700; background: var(--bg3); }
268
+ .tp-mtag { display: inline-block; padding: 1px 6px; border-radius: 3px; border: 1px solid; font-size: 10px; margin: 1px; font-weight: 500; }
269
+ .tp-mbreak { white-space: normal; min-width: 200px; }
270
+
211
271
  /* ── Stream panel ── */
212
272
  #stream-panel-wrap {
213
273
  flex: 1; display: flex; flex-direction: column; overflow: hidden;
@@ -380,6 +440,9 @@ body {
380
440
  <body>
381
441
 
382
442
  <div id="header">
443
+ <button class="btn on" id="tab-stream" onclick="switchTab('stream')">📡 Stream</button>
444
+ <button class="btn" id="tab-tokens" onclick="switchTab('tokens')">📊 Tokens</button>
445
+ <span class="sep">│</span>
383
446
  <button class="btn on" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
384
447
  <button class="btn on" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
385
448
  <button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
@@ -424,6 +487,28 @@ body {
424
487
  </div>
425
488
  </div>
426
489
 
490
+ <div id="tokens-page" style="display:none">
491
+ <!-- Top row: left sidebar + right content -->
492
+ <div class="tp-top">
493
+ <div class="tp-left">
494
+ <div class="tp-box" id="tp-total-card"></div>
495
+ <div class="tp-box" id="tp-stats-grid"></div>
496
+ <div class="tp-box" id="tp-model-rank"></div>
497
+ </div>
498
+ <div class="tp-right">
499
+ <div class="tp-box" id="tp-trend-card"></div>
500
+ <div class="tp-box" id="tp-heatmap-card"></div>
501
+ </div>
502
+ </div>
503
+ <!-- Detail table -->
504
+ <div class="tp-box">
505
+ <div class="tp-tabs" id="tp-detail-tabs"></div>
506
+ <div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
507
+ <div id="tp-tc-weekly" class="tp-tc"><div class="tp-st" id="tp-weekly-table"></div></div>
508
+ <div id="tp-tc-monthly" class="tp-tc"><div class="tp-st" id="tp-monthly-table"></div></div>
509
+ </div>
510
+ </div>
511
+
427
512
  <div id="footer">
428
513
  <span id="scroll-pos">0%</span>
429
514
  <span class="sep">│</span>
@@ -516,6 +601,8 @@ let showActivity = true;
516
601
  let showTokenCount = true;
517
602
  let autoDiscovery = true;
518
603
  let appVersion = '';
604
+ let currentTab = 'stream';
605
+ let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {} };
519
606
 
520
607
  const HIDDEN_KEY = 'claude-watch-hidden';
521
608
  function loadHiddenSessions() {
@@ -546,8 +633,8 @@ function computeTokensFromContext() {
546
633
  for (const ctx of Object.values(contextData)) {
547
634
  totalInput += ctx.inputTokens || 0;
548
635
  totalOutput += ctx.outputTokens || 0;
549
- totalCacheCreate += ctx.cacheCreationTokens || 0;
550
- totalCacheRead += ctx.cacheReadTokens || 0;
636
+ totalCacheCreate += ctx.cacheCreation || 0;
637
+ totalCacheRead += ctx.cacheRead || 0;
551
638
  }
552
639
  }
553
640
 
@@ -677,6 +764,7 @@ function handleMessage(msg) {
677
764
  case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
678
765
  case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
679
766
  case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
767
+ case 'tokenStats': tokenStatsData = msg.payload; if (currentTab === 'tokens') renderTokenPage(); break;
680
768
  case 'config':
681
769
  if (msg.payload.version) appVersion = msg.payload.version;
682
770
  if (msg.payload.collapseAfter > 0 && !collapseTimer) {
@@ -1630,6 +1718,366 @@ function toggleTokenDisplay() {
1630
1718
  scheduleRender();
1631
1719
  refreshButtons();
1632
1720
  }
1721
+
1722
+ // ══════════════════════════════════════════════════════════════════════════════
1723
+ // Tab switching & Token stats page
1724
+ // ══════════════════════════════════════════════════════════════════════════════
1725
+ // Token Statistics (completely independent from stream/context)
1726
+ // ══════════════════════════════════════════════════════════════════════════════
1727
+
1728
+ const MODEL_COLORS = {
1729
+ 'claude-opus-4-7': '#e74c3c', 'claude-opus-4-6': '#c0392b', 'claude-opus-4-8': '#e67e22',
1730
+ 'claude-sonnet-4-6': '#3498db', 'claude-sonnet-4-5': '#2980b9',
1731
+ 'claude-haiku-4-5': '#5dade2', 'claude-haiku-4': '#1abc9c',
1732
+ 'glm-5.1': '#2980b9', 'glm-5': '#3498db', 'glm-4.7': '#5dade2',
1733
+ 'qwen3.7-max': '#55efc4', 'qwen3.6-plus': '#2ecc71', 'qwen3.5-plus': '#27ae60',
1734
+ 'qwen3-max': '#1abc9c',
1735
+ 'deepseek-v4-pro': '#9b59b6',
1736
+ 'kimi-k2.5': '#f39c12', 'kimi-k2.6': '#d35400', 'kimi-k2-thinking': '#d4a017',
1737
+ 'MiniMax-M2.5': '#1abc9c',
1738
+ };
1739
+ let _modelColorIdx = 0;
1740
+ function modelColor(name) {
1741
+ if (MODEL_COLORS[name]) return MODEL_COLORS[name];
1742
+ const fallback = ['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#1abc9c','#e67e22','#c0392b','#5dade2','#d35400','#55efc4','#d4a017'];
1743
+ return fallback[_modelColorIdx++ % fallback.length];
1744
+ }
1745
+
1746
+ let tsDetailTab = 'daily';
1747
+
1748
+ function switchTab(tab) {
1749
+ currentTab = tab;
1750
+ document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
1751
+ document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
1752
+ document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
1753
+ document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
1754
+ document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
1755
+ if (tab === 'tokens') renderTokenPage();
1756
+ }
1757
+
1758
+ function tsSwitchDetail(n) {
1759
+ tsDetailTab = n;
1760
+ document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
1761
+ document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
1762
+ document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
1763
+ document.getElementById('tp-tc-' + n)?.classList.add('active');
1764
+ }
1765
+
1766
+ function fmtTS(n) {
1767
+ if (!n) return '0';
1768
+ return n.toLocaleString();
1769
+ }
1770
+
1771
+ function fmtDateISO(d) {
1772
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
1773
+ }
1774
+
1775
+ // ── Heatmap: 52-week × 7-day GitHub-style grid ──
1776
+ function buildHeatmap(daily) {
1777
+ const today = new Date();
1778
+ const dailyTotalsMap = {};
1779
+ for (const [k, d] of Object.entries(daily)) {
1780
+ dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
1781
+ }
1782
+
1783
+ // Start 52 weeks ago from Sunday
1784
+ const startSunday = new Date(today);
1785
+ startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
1786
+ const startStr = fmtDateISO(startSunday);
1787
+
1788
+ // Compute maxVal only from dates within the heatmap window
1789
+ let maxVal = 0;
1790
+ for (const [k, v] of Object.entries(dailyTotalsMap)) {
1791
+ if (k >= startStr && v > maxVal) maxVal = v;
1792
+ }
1793
+
1794
+ const weeks = [];
1795
+ const monthLabels = [];
1796
+ let lastMonth = -1;
1797
+ let currentSunday = new Date(startSunday);
1798
+
1799
+ for (let w = 0; w < 53; w++) {
1800
+ const weekData = [];
1801
+ for (let dow = 0; dow < 7; dow++) {
1802
+ const d = new Date(currentSunday);
1803
+ d.setDate(d.getDate() + dow);
1804
+ const ds = fmtDateISO(d);
1805
+ const val = dailyTotalsMap[ds] || 0;
1806
+ weekData.push({ date: ds, val, future: d > today });
1807
+ if (dow === 0) {
1808
+ const m = d.getMonth();
1809
+ if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
1810
+ }
1811
+ }
1812
+ weeks.push(weekData);
1813
+ currentSunday.setDate(currentSunday.getDate() + 7);
1814
+ }
1815
+
1816
+ function cellColor(val, future) {
1817
+ if (future) return 'var(--bg3)';
1818
+ if (val === 0) return '#0d423d';
1819
+ const pct = maxVal > 0 ? val / maxVal : 0;
1820
+ if (pct < 0.25) return '#0e6b5a';
1821
+ if (pct < 0.5) return '#12b886';
1822
+ if (pct < 0.75) return '#34d399';
1823
+ return '#6ee7b7';
1824
+ }
1825
+
1826
+ const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1827
+ let monthsHTML = '<div class="tp-hm-months">';
1828
+ let prevWeek = 0;
1829
+ for (const ml of monthLabels) {
1830
+ const offset = ml.week - prevWeek;
1831
+ if (offset > 0) monthsHTML += `<span style="width:${offset * 14}px"></span>`;
1832
+ monthsHTML += `<span style="width:14px">${monthNames[ml.month]}</span>`;
1833
+ prevWeek = ml.week + 1;
1834
+ }
1835
+ monthsHTML += '</div>';
1836
+
1837
+ const dayLabels = ['','Mon','','Wed','','Fri',''];
1838
+ let gridHTML = '';
1839
+ for (let dow = 0; dow < 7; dow++) {
1840
+ gridHTML += `<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`;
1841
+ for (let w = 0; w < weeks.length; w++) {
1842
+ const cell = weeks[w][dow];
1843
+ const bg = cellColor(cell.val, cell.future);
1844
+ const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
1845
+ gridHTML += `<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`;
1846
+ }
1847
+ gridHTML += '</div>';
1848
+ }
1849
+
1850
+ let legendHTML = '<div class="tp-hm-legend"><span>Less</span>';
1851
+ legendHTML += '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>';
1852
+ legendHTML += '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>';
1853
+ legendHTML += '<span class="tp-hm-legend-cell" style="background:#12b886"></span>';
1854
+ legendHTML += '<span class="tp-hm-legend-cell" style="background:#34d399"></span>';
1855
+ legendHTML += '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>';
1856
+ legendHTML += '<span>More</span></div>';
1857
+
1858
+ return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
1859
+ }
1860
+
1861
+ // ── Trend: bar chart for last 30 days ──
1862
+ function buildTrend(daily) {
1863
+ const keys = Object.keys(daily).sort();
1864
+ const recentKeys = keys.slice(-30);
1865
+ if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
1866
+
1867
+ const values = recentKeys.map(k => {
1868
+ const d = daily[k];
1869
+ return d.input + d.output + d.cacheCreation + d.cacheRead;
1870
+ });
1871
+ const maxVal = Math.max(...values);
1872
+
1873
+ let barsHTML = '';
1874
+ for (let i = 0; i < recentKeys.length; i++) {
1875
+ const k = recentKeys[i];
1876
+ const v = values[i];
1877
+ const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
1878
+ const label = k.slice(5);
1879
+ const tip = `${k}: ${fmtTS(v)}`;
1880
+ const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
1881
+ barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${tip}"></div><span class="tp-trend-label">${label}</span></div>`;
1882
+ }
1883
+
1884
+ const gridLines = `<div class="tp-trend-grid-lines">
1885
+ <span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
1886
+ <div class="tp-trend-grid-line"></div>
1887
+ <div class="tp-trend-grid-line"></div>
1888
+ <span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
1889
+ <div class="tp-trend-grid-line"></div>
1890
+ <span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
1891
+ </div>`;
1892
+
1893
+ return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
1894
+ }
1895
+
1896
+ // ── Model ranking sidebar ──
1897
+ function buildModelRank(mt, totalAll) {
1898
+ const sorted = Object.entries(mt).sort((a, b) => {
1899
+ const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
1900
+ const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
1901
+ return sB - sA;
1902
+ });
1903
+
1904
+ let html = '<div class="tp-rank-title">🏆 Model Ranking</div>';
1905
+ for (let i = 0; i < Math.min(sorted.length, 5); i++) {
1906
+ const [name, m] = sorted[i];
1907
+ const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
1908
+ const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
1909
+ const c = modelColor(name);
1910
+ html += `<div class="tp-rank-item">
1911
+ <span class="tp-rank-num">${i + 1}</span>
1912
+ <span class="tp-rank-dot" style="background:${c}"></span>
1913
+ <span class="tp-rank-name">${esc(name)}</span>
1914
+ <span class="tp-rank-pct">${pct}%</span>
1915
+ </div>`;
1916
+ }
1917
+ return html;
1918
+ }
1919
+
1920
+ // ── Render entire token page ──
1921
+ function renderTokenPage() {
1922
+ const t = tokenStatsData.totals;
1923
+ const mt = tokenStatsData.modelTotals;
1924
+ const daily = tokenStatsData.daily;
1925
+ const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
1926
+
1927
+ if (totalAll === 0) {
1928
+ document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
1929
+ document.getElementById('tp-stats-grid').innerHTML = '';
1930
+ document.getElementById('tp-model-rank').innerHTML = '';
1931
+ document.getElementById('tp-trend-card').innerHTML = '';
1932
+ document.getElementById('tp-heatmap-card').innerHTML = '';
1933
+ document.getElementById('tp-detail-tabs').innerHTML = '';
1934
+ document.getElementById('tp-daily-table').innerHTML = '';
1935
+ document.getElementById('tp-weekly-table').innerHTML = '';
1936
+ document.getElementById('tp-monthly-table').innerHTML = '';
1937
+ return;
1938
+ }
1939
+
1940
+ const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
1941
+ const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
1942
+ const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
1943
+ const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
1944
+ const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
1945
+
1946
+ // 1. Total tokens card
1947
+ document.getElementById('tp-total-card').innerHTML = `
1948
+ <div class="tp-total-label">TOTAL TOKENS</div>
1949
+ <div class="tp-total-value">${fmtTS(totalAll)}</div>
1950
+ <div class="tp-footer-stats">
1951
+ <span>Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
1952
+ <span>Active <span class="tp-fv">${t.days} DAY</span></span>
1953
+ <span>Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
1954
+ </div>`;
1955
+
1956
+ // 2. Stats grid
1957
+ const stats = [
1958
+ { l: 'Input', v: fmtTS(t.input), s: inputPct + '%' },
1959
+ { l: 'Output', v: fmtTS(t.output), s: outputPct + '%' },
1960
+ { l: 'Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
1961
+ { l: 'Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
1962
+ { l: 'Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
1963
+ { l: 'Daily Avg', v: dailyAvg, s: 'tokens/day' },
1964
+ ];
1965
+ document.getElementById('tp-stats-grid').innerHTML = `<div class="tp-stat-grid">${stats.map(s => `<div class="tp-stat"><div class="tp-s-l">${s.l}</div><div class="tp-s-v">${s.v}</div><div style="font-size:9px;color:var(--dim)">${s.s}</div></div>`).join('')}</div>`;
1966
+
1967
+ // 3. Model ranking
1968
+ document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
1969
+
1970
+ // 4. Usage Trend
1971
+ document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 Usage Trend</div>${buildTrend(daily)}`;
1972
+
1973
+ // 5. Activity Heatmap
1974
+ const tzOffset = -(new Date().getTimezoneOffset() / 60);
1975
+ document.getElementById('tp-heatmap-card').innerHTML = `<div class="tp-h3">🗓 Activity Heatmap</div><span style="font-size:10px;color:var(--dim);float:right">UTC+${tzOffset.toFixed(0)}</span>${buildHeatmap(daily)}`;
1976
+
1977
+ // 6. Detail tabs
1978
+ const dailyKeys = Object.keys(daily);
1979
+ const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
1980
+ const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
1981
+ document.getElementById('tp-detail-tabs').innerHTML = `<div class="tp-tab ${tsDetailTab === 'daily' ? 'active' : ''}" data-tab="daily" onclick="tsSwitchDetail('daily')">Daily Breakdown (${dailyKeys.length})</div><div class="tp-tab ${tsDetailTab === 'weekly' ? 'active' : ''}" data-tab="weekly" onclick="tsSwitchDetail('weekly')">Weekly (${weeklyCount})</div><div class="tp-tab ${tsDetailTab === 'monthly' ? 'active' : ''}" data-tab="monthly" onclick="tsSwitchDetail('monthly')">Monthly (${monthlyCount})</div>`;
1982
+ document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
1983
+ document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
1984
+
1985
+ document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
1986
+ const weekly = aggregateWeekly(dailyKeys, daily);
1987
+ document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
1988
+ const monthly = aggregateMonthly(dailyKeys, daily);
1989
+ document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
1990
+ }
1991
+
1992
+ function weeklyKeysFromDaily(keys) {
1993
+ const weeks = new Set();
1994
+ for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
1995
+ return [...weeks];
1996
+ }
1997
+ function monthlyKeysFromDaily(keys) {
1998
+ const months = new Set();
1999
+ for (const k of keys) { months.add(k.slice(0, 7)); }
2000
+ return [...months];
2001
+ }
2002
+
2003
+ function getWeekKey(d) {
2004
+ const dayNum = d.getDay() || 7; // Sunday (0) becomes 7 for ISO week calculation
2005
+ const thursday = new Date(d);
2006
+ thursday.setDate(d.getDate() + 4 - dayNum); // ISO Thursday offset
2007
+ const year = thursday.getFullYear();
2008
+ const jan1 = new Date(year, 0, 1);
2009
+ const wk = Math.ceil(((thursday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
2010
+ return year + '-W' + String(wk).padStart(2, '0');
2011
+ }
2012
+
2013
+ function aggregateWeekly(dailyKeys, daily) {
2014
+ const result = {};
2015
+ for (const k of dailyKeys) {
2016
+ const d = new Date(k);
2017
+ const wk = getWeekKey(d);
2018
+ if (!result[wk]) result[wk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
2019
+ else result[wk].dateRange += ' ~ ' + k;
2020
+ const day = daily[k];
2021
+ result[wk].messages += day.messages;
2022
+ result[wk].input += day.input;
2023
+ result[wk].output += day.output;
2024
+ result[wk].cacheCreation += day.cacheCreation;
2025
+ result[wk].cacheRead += day.cacheRead;
2026
+ for (const [mn, m] of Object.entries(day.models)) {
2027
+ if (!result[wk].models[mn]) result[wk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
2028
+ result[wk].models[mn].input += m.input;
2029
+ result[wk].models[mn].output += m.output;
2030
+ result[wk].models[mn].cacheCreation += m.cacheCreation;
2031
+ result[wk].models[mn].cacheRead += m.cacheRead;
2032
+ }
2033
+ }
2034
+ return result;
2035
+ }
2036
+
2037
+ function aggregateMonthly(dailyKeys, daily) {
2038
+ const result = {};
2039
+ for (const k of dailyKeys) {
2040
+ const mk = k.slice(0, 7);
2041
+ if (!result[mk]) result[mk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
2042
+ else result[mk].dateRange += ' ~ ' + k.slice(5);
2043
+ const day = daily[k];
2044
+ result[mk].messages += day.messages;
2045
+ result[mk].input += day.input;
2046
+ result[mk].output += day.output;
2047
+ result[mk].cacheCreation += day.cacheCreation;
2048
+ result[mk].cacheRead += day.cacheRead;
2049
+ for (const [mn, m] of Object.entries(day.models)) {
2050
+ if (!result[mk].models[mn]) result[mk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
2051
+ result[mk].models[mn].input += m.input;
2052
+ result[mk].models[mn].output += m.output;
2053
+ result[mk].models[mn].cacheCreation += m.cacheCreation;
2054
+ result[mk].models[mn].cacheRead += m.cacheRead;
2055
+ }
2056
+ }
2057
+ return result;
2058
+ }
2059
+
2060
+ function renderPeriodTable(keys, data, type) {
2061
+ const sorted = keys.sort((a, b) => b.localeCompare(a));
2062
+ let html = '<table class="tp-table"><thead><tr><th>Date</th><th>Total</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cache Create</th><th>Messages</th><th>Models</th></tr></thead><tbody>';
2063
+ for (const k of sorted) {
2064
+ const d = data[k];
2065
+ const total = d.input + d.output + d.cacheCreation + d.cacheRead;
2066
+ const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
2067
+ const modelsHtml = Object.entries(d.models).sort((a, b) => {
2068
+ const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
2069
+ const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
2070
+ return sB - sA;
2071
+ }).slice(0, 4).map(([mn, m]) => {
2072
+ const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
2073
+ const c = modelColor(mn);
2074
+ return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
2075
+ }).join(' ');
2076
+ html += `<tr><td>${label}</td><td><b>${fmtTS(total)}</b></td><td>${fmtTS(d.input)}</td><td>${fmtTS(d.output)}</td><td>${fmtTS(d.cacheRead)}</td><td>${fmtTS(d.cacheCreation)}</td><td>${d.messages.toLocaleString()}</td><td class="tp-mbreak">${modelsHtml}</td></tr>`;
2077
+ }
2078
+ html += '</tbody></table>';
2079
+ return html;
2080
+ }
1633
2081
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1634
2082
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1635
2083
  function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+ var path = require('path');
5
+ var os = require('os');
6
+ var readline = require('readline');
7
+
8
+ // ── Walk directory recursively ──
9
+ async function walkDir(dir, callback) {
10
+ try {
11
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
12
+ for (const entry of entries) {
13
+ const fullPath = path.join(dir, entry.name);
14
+ if (entry.isDirectory()) {
15
+ await walkDir(fullPath, callback);
16
+ } else {
17
+ try {
18
+ const stats = await fs.promises.stat(fullPath);
19
+ callback(fullPath, stats);
20
+ } catch { /* skip */ }
21
+ }
22
+ }
23
+ } catch (err) {
24
+ if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
25
+ console.error('[scanner] walkDir error on ' + dir + ': ' + err.message);
26
+ }
27
+ }
28
+ }
29
+
30
+ function isJsonlFile(filePath, stats) {
31
+ if (!stats.isFile()) return false;
32
+ return path.extname(filePath) === '.jsonl';
33
+ }
34
+
35
+ // ── Get Claude projects directory ──
36
+ function getClaudeDir() {
37
+ return path.join(os.homedir(), '.claude', 'projects');
38
+ }
39
+
40
+ /**
41
+ * Full-scan all JSONL files under ~/.claude/projects,
42
+ * extract token usage data, aggregate by date.
43
+ * Returns a Map: "YYYY-MM-DD" → { messages, input, output, cacheCreation, cacheRead, models: { modelName: { ... } } }
44
+ */
45
+ async function fullScanTokenUsage(progressCallback) {
46
+ const claudeDir = getClaudeDir();
47
+ const dailyStats = new Map();
48
+
49
+ // Collect all JSONL files (main + subagent)
50
+ const jsonlFiles = [];
51
+ await walkDir(claudeDir, (filePath, stats) => {
52
+ if (isJsonlFile(filePath, stats)) {
53
+ jsonlFiles.push(filePath);
54
+ }
55
+ });
56
+
57
+ if (progressCallback) progressCallback(0, jsonlFiles.length);
58
+
59
+ // Process each file
60
+ for (let i = 0; i < jsonlFiles.length; i++) {
61
+ await scanOneFile(jsonlFiles[i], dailyStats);
62
+ if (progressCallback && (i % 50 === 0 || i === jsonlFiles.length - 1)) {
63
+ progressCallback(i + 1, jsonlFiles.length);
64
+ }
65
+ }
66
+
67
+ return dailyStats;
68
+ }
69
+
70
+ async function scanOneFile(filePath, dailyStats) {
71
+ let input, rl;
72
+ try {
73
+ input = fs.createReadStream(filePath, { encoding: 'utf-8' });
74
+ rl = readline.createInterface({ input, crlfDelay: Infinity });
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ for await (const line of rl) {
80
+ // Fast pre-filter: only parse lines containing "usage"
81
+ if (!line.includes('"usage"')) continue;
82
+ // Also need model for per-model breakdown
83
+ const hasModel = line.includes('"model"');
84
+
85
+ let raw;
86
+ try {
87
+ raw = JSON.parse(line);
88
+ } catch {
89
+ continue;
90
+ }
91
+
92
+ const msg = raw.message;
93
+ if (!msg) continue;
94
+
95
+ // Extract timestamp — required for date-based aggregation
96
+ let ts;
97
+ if (raw.timestamp) {
98
+ ts = new Date(raw.timestamp);
99
+ }
100
+ if (!raw.timestamp || isNaN(ts.getTime())) {
101
+ // Skip lines without valid timestamps — can't determine which day they belong to
102
+ continue;
103
+ }
104
+ const dateStr = ts.getFullYear() + '-' + String(ts.getMonth() + 1).padStart(2, '0') + '-' + String(ts.getDate()).padStart(2, '0');
105
+
106
+ // Extract usage
107
+ const usage = msg.usage;
108
+ if (!usage) continue;
109
+
110
+ const inputTokens = usage.input_tokens || 0;
111
+ const outputTokens = usage.output_tokens || 0;
112
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
113
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
114
+
115
+ if (!inputTokens && !outputTokens && !cacheCreationTokens && !cacheReadTokens) continue;
116
+
117
+ // Get or create day entry
118
+ let day = dailyStats.get(dateStr);
119
+ if (!day) {
120
+ day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
121
+ dailyStats.set(dateStr, day);
122
+ }
123
+
124
+ day.messages++;
125
+ day.input += inputTokens;
126
+ day.output += outputTokens;
127
+ day.cacheCreation += cacheCreationTokens;
128
+ day.cacheRead += cacheReadTokens;
129
+
130
+ // Per-model breakdown
131
+ const model = (hasModel && msg.model && msg.model !== '<synthetic>') ? msg.model : '';
132
+ if (model) {
133
+ let m = day.models[model];
134
+ if (!m) {
135
+ m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
136
+ day.models[model] = m;
137
+ }
138
+ m.input += inputTokens;
139
+ m.output += outputTokens;
140
+ m.cacheCreation += cacheCreationTokens;
141
+ m.cacheRead += cacheReadTokens;
142
+ }
143
+ }
144
+ }
145
+
146
+ module.exports = { fullScanTokenUsage, getClaudeDir };
@@ -9,6 +9,7 @@ var readline = require('readline');
9
9
  var { WebSocketServer } = require('ws');
10
10
  var { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
11
11
  var { setDebugAll, contextWindowFor } = require('../parser/parser');
12
+ var { fullScanTokenUsage } = require('../scanner/scanner');
12
13
 
13
14
  var PACKAGE_VERSION = require('../../package.json').version;
14
15
 
@@ -37,6 +38,11 @@ class DashboardServer {
37
38
  this._contextCleanupTimer = null;
38
39
  this._pendingItems = [];
39
40
  this._flushTimer = null;
41
+ this._tokenStatsDirty = false;
42
+
43
+ // Time-series token stats: daily aggregation (never cleaned up)
44
+ // Key: "YYYY-MM-DD", value: { messages, input, output, cacheCreation, cacheRead, models: { modelName: { input, output, cacheCreation, cacheRead } } }
45
+ this.dailyStats = new Map();
40
46
 
41
47
  this.server = null;
42
48
  this.wss = null;
@@ -67,6 +73,12 @@ class DashboardServer {
67
73
  return Date.now();
68
74
  }
69
75
 
76
+ _getDateKey(ts) {
77
+ let d = new Date(ts);
78
+ if (isNaN(d.getTime())) d = new Date();
79
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
80
+ }
81
+
70
82
  updateContext(item) {
71
83
  const key = this.getCtxKey(item.sessionID, item.agentID);
72
84
  let ctx = this.contextMap.get(key);
@@ -85,6 +97,36 @@ class DashboardServer {
85
97
  ctx.contextWindow = contextWindowFor(item.model);
86
98
  }
87
99
  ctx.lastActivity = Math.max(ctx.lastActivity || 0, this.itemTime(item));
100
+
101
+ // ── Time-series aggregation for token stats ──
102
+ // All 4 token fields are summed (incremental for billing/consumption perspective)
103
+ const hasTokens = item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens;
104
+ if (hasTokens) {
105
+ const dateKey = this._getDateKey(this.itemTime(item));
106
+ let day = this.dailyStats.get(dateKey);
107
+ if (!day) {
108
+ day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
109
+ this.dailyStats.set(dateKey, day);
110
+ }
111
+ day.messages++;
112
+ if (item.inputTokens) day.input += item.inputTokens;
113
+ if (item.outputTokens) day.output += item.outputTokens;
114
+ if (item.cacheCreationTokens) day.cacheCreation += item.cacheCreationTokens;
115
+ if (item.cacheReadTokens) day.cacheRead += item.cacheReadTokens;
116
+
117
+ // Per-model breakdown within this day
118
+ if (item.model) {
119
+ let m = day.models[item.model];
120
+ if (!m) {
121
+ m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
122
+ day.models[item.model] = m;
123
+ }
124
+ if (item.inputTokens) m.input += item.inputTokens;
125
+ if (item.outputTokens) m.output += item.outputTokens;
126
+ if (item.cacheCreationTokens) m.cacheCreation += item.cacheCreationTokens;
127
+ if (item.cacheReadTokens) m.cacheRead += item.cacheReadTokens;
128
+ }
129
+ }
88
130
  }
89
131
 
90
132
  cleanupContextMap() {
@@ -112,6 +154,47 @@ class DashboardServer {
112
154
  return result;
113
155
  }
114
156
 
157
+ getTokenStatsSnapshot() {
158
+ // Convert dailyStats Map to plain object, sorted by date descending
159
+ const daily = {};
160
+ const sortedKeys = [...this.dailyStats.keys()].sort().reverse();
161
+ for (const k of sortedKeys) {
162
+ const d = this.dailyStats.get(k);
163
+ daily[k] = {
164
+ messages: d.messages,
165
+ input: d.input,
166
+ output: d.output,
167
+ cacheCreation: d.cacheCreation,
168
+ cacheRead: d.cacheRead,
169
+ models: d.models,
170
+ };
171
+ }
172
+
173
+ // Compute global totals
174
+ let totalMessages = 0, totalInput = 0, totalOutput = 0, totalCacheCreation = 0, totalCacheRead = 0;
175
+ const modelTotals = {};
176
+ for (const [, d] of this.dailyStats) {
177
+ totalMessages += d.messages;
178
+ totalInput += d.input;
179
+ totalOutput += d.output;
180
+ totalCacheCreation += d.cacheCreation;
181
+ totalCacheRead += d.cacheRead;
182
+ for (const [modelName, m] of Object.entries(d.models)) {
183
+ if (!modelTotals[modelName]) modelTotals[modelName] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
184
+ modelTotals[modelName].input += m.input;
185
+ modelTotals[modelName].output += m.output;
186
+ modelTotals[modelName].cacheCreation += m.cacheCreation;
187
+ modelTotals[modelName].cacheRead += m.cacheRead;
188
+ }
189
+ }
190
+
191
+ return {
192
+ totals: { messages: totalMessages, input: totalInput, output: totalOutput, cacheCreation: totalCacheCreation, cacheRead: totalCacheRead, days: this.dailyStats.size },
193
+ modelTotals,
194
+ daily,
195
+ };
196
+ }
197
+
115
198
  broadcast(type, payload) {
116
199
  const msg = JSON.stringify({ type, payload });
117
200
  const toRemove = [];
@@ -207,6 +290,11 @@ class DashboardServer {
207
290
  return;
208
291
  }
209
292
 
293
+ if (route === '/token-stats') {
294
+ this.sendJSON(res, this.getTokenStatsSnapshot());
295
+ return;
296
+ }
297
+
210
298
  if (route === '/task-output') {
211
299
  const filePath = params.get('path');
212
300
  if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
@@ -262,6 +350,7 @@ class DashboardServer {
262
350
  this.sendSnapshot(ws);
263
351
  this.sendItemBatch(ws);
264
352
  this.sendContext(ws);
353
+ this.sendTokenStats(ws);
265
354
  this.sendConfig(ws);
266
355
  }
267
356
 
@@ -294,6 +383,10 @@ class DashboardServer {
294
383
  try { ws.send(JSON.stringify({ type, payload })); } catch {}
295
384
  }
296
385
 
386
+ sendTokenStats(ws) {
387
+ this.send(ws, 'tokenStats', this.getTokenStatsSnapshot());
388
+ }
389
+
297
390
  sendSnapshot(ws) {
298
391
  if (!this.watcher) return;
299
392
  const sessions = this.watcher.getSessionsSnapshot().map(s => ({
@@ -359,12 +452,20 @@ class DashboardServer {
359
452
  }
360
453
  this.updateContext(item);
361
454
  this._pendingItems.push(item);
455
+ // Track if any item in this batch has token data (for tokenStats broadcast)
456
+ if (item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens) {
457
+ this._tokenStatsDirty = true;
458
+ }
362
459
  if (this._pendingItems.length >= FLUSH_BATCH_LIMIT) {
363
460
  // Batch size hit limit — flush immediately
364
461
  if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
365
462
  const batch = this._pendingItems;
366
463
  this._pendingItems = [];
367
464
  this.broadcast('itemBatch', batch);
465
+ if (this._tokenStatsDirty) {
466
+ this._tokenStatsDirty = false;
467
+ this.broadcast('tokenStats', this.getTokenStatsSnapshot());
468
+ }
368
469
  } else if (!this._flushTimer) {
369
470
  this._flushTimer = setTimeout(() => {
370
471
  this._flushTimer = null;
@@ -375,6 +476,10 @@ class DashboardServer {
375
476
  } else if (batch.length > 1) {
376
477
  this.broadcast('itemBatch', batch);
377
478
  }
479
+ if (this._tokenStatsDirty) {
480
+ this._tokenStatsDirty = false;
481
+ this.broadcast('tokenStats', this.getTokenStatsSnapshot());
482
+ }
378
483
  }, 50);
379
484
  }
380
485
  });
@@ -497,6 +602,26 @@ class DashboardServer {
497
602
  }
498
603
  });
499
604
 
605
+ // ── Full-scan historical JSONL files for token stats ──
606
+ // This runs BEFORE watcher starts, scanning ALL files regardless of age
607
+ console.log(' Scanning historical token data...');
608
+ try {
609
+ const scannedDaily = await fullScanTokenUsage((done, total) => {
610
+ if (total > 0 && (done % 100 === 0 || done === total)) {
611
+ console.log(` Scanned ${done}/${total} files...`);
612
+ }
613
+ });
614
+ // Merge scanned data into this.dailyStats
615
+ for (const [dateStr, day] of scannedDaily) {
616
+ this.dailyStats.set(dateStr, day);
617
+ }
618
+ const totalDays = this.dailyStats.size;
619
+ const totalMsgs = [...this.dailyStats.values()].reduce((s, d) => s + d.messages, 0);
620
+ console.log(` Token scan complete: ${totalDays} days, ${totalMsgs.toLocaleString()} messages`);
621
+ } catch (err) {
622
+ console.error(' Token scan error (non-critical, continuing):', err.message);
623
+ }
624
+
500
625
  const w = this.setupWatcher(watcherOpts);
501
626
 
502
627
  try {
@@ -581,4 +706,4 @@ function askYesNo(prompt) {
581
706
  });
582
707
  }
583
708
 
584
- module.exports = { DashboardServer, startServer };
709
+ module.exports = { DashboardServer, startServer };