claude-code-watch 0.1.4 → 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.4",
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
@@ -209,31 +209,64 @@ body {
209
209
  .tree-row.selected>.tree-actions { display: flex; }
210
210
 
211
211
  /* ── Tokens page ── */
212
- #tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 16px 24px; gap: 16px; background: var(--bg); }
213
- .token-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
214
- .token-card-title { font-size: 13px; font-weight: 600; color: var(--white); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
215
- .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
216
- .token-item { display: flex; flex-direction: column; gap: 2px; }
217
- .token-label { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; }
218
- .token-value { font-size: 16px; font-weight: 600; color: var(--white); font-family: monospace; }
219
- .token-bar { height: 4px; border-radius: 2px; background: var(--bg3); margin-top: 4px; }
220
- .token-bar-fill { height: 100%; border-radius: 2px; background: var(--purple); transition: width 0.3s; }
221
- .token-bar-fill.warn { background: var(--yellow); }
222
- .token-bar-fill.danger { background: var(--red); }
223
- .token-section-title { font-size: 12px; font-weight: 600; color: var(--dim); margin: 8px 0 4px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
224
- .token-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; }
225
- .token-table th { background: var(--bg3); color: var(--white); padding: 6px 8px; text-align: left; font-weight: 600; font-size: 11px; border-bottom: 1px solid var(--border); }
226
- .token-table td { padding: 5px 8px; color: var(--text); border-bottom: 1px solid var(--border); font-family: monospace; }
227
- .token-table tr:hover td { background: var(--bg3); }
228
- .token-detail-row { display: flex; gap: 4px; margin-top: 6px; font-size: 12px; flex-wrap: wrap; }
229
- .token-detail-row .token-kv { color: var(--dim); }
230
- .token-detail-row .token-kv b { color: var(--text); font-family: monospace; }
231
- .token-usage-line { display: flex; align-items: baseline; gap: 8px; margin-top: 2px; }
232
- .token-usage-line .token-pct { font-size: 12px; font-weight: 600; }
233
- .token-usage-line .token-pct.warn { color: var(--yellow); }
234
- .token-usage-line .token-pct.danger { color: var(--red); }
235
- .token-usage-line .token-ctx-info { font-size: 11px; color: var(--dim); }
236
- .token-active-dot { font-size: 10px; }
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; }
237
270
 
238
271
  /* ── Stream panel ── */
239
272
  #stream-panel-wrap {
@@ -455,18 +488,24 @@ body {
455
488
  </div>
456
489
 
457
490
  <div id="tokens-page" style="display:none">
458
- <div class="token-card" id="token-overview">
459
- <div class="token-card-title">📊 Token Overview</div>
460
- <div class="token-grid" id="token-overview-grid"></div>
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>
461
502
  </div>
462
- <div class="token-section-title">按 Agent 分项</div>
463
- <div id="token-agent-cards"></div>
464
- <div class="token-section-title" style="cursor:pointer" onclick="toggleTokenTable()" id="token-table-toggle">明细表格 ▸</div>
465
- <div id="token-table-wrap" style="display:none">
466
- <table class="token-table" id="token-detail-table">
467
- <thead><tr><th>Agent</th><th>Model</th><th>Input</th><th>Output</th><th>Cache+</th><th>Cache Read</th><th>Context</th><th>%</th><th>I/O</th></tr></thead>
468
- <tbody id="token-table-body"></tbody>
469
- </table>
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>
470
509
  </div>
471
510
  </div>
472
511
 
@@ -563,6 +602,7 @@ let showTokenCount = true;
563
602
  let autoDiscovery = true;
564
603
  let appVersion = '';
565
604
  let currentTab = 'stream';
605
+ let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {} };
566
606
 
567
607
  const HIDDEN_KEY = 'claude-watch-hidden';
568
608
  function loadHiddenSessions() {
@@ -723,7 +763,8 @@ function handleMessage(msg) {
723
763
  case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
724
764
  case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
725
765
  case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
726
- case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); if (currentTab === 'tokens') renderTokenPage(); break;
766
+ case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
767
+ case 'tokenStats': tokenStatsData = msg.payload; if (currentTab === 'tokens') renderTokenPage(); break;
727
768
  case 'config':
728
769
  if (msg.payload.version) appVersion = msg.payload.version;
729
770
  if (msg.payload.collapseAfter > 0 && !collapseTimer) {
@@ -1679,104 +1720,363 @@ function toggleTokenDisplay() {
1679
1720
  }
1680
1721
 
1681
1722
  // ══════════════════════════════════════════════════════════════════════════════
1682
- // Tab switching & Token page
1723
+ // Tab switching & Token stats page
1724
+ // ══════════════════════════════════════════════════════════════════════════════
1725
+ // Token Statistics (completely independent from stream/context)
1683
1726
  // ══════════════════════════════════════════════════════════════════════════════
1684
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
+
1685
1748
  function switchTab(tab) {
1686
1749
  currentTab = tab;
1687
1750
  document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
1688
1751
  document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
1689
1752
  document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
1690
1753
  document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
1691
- // footer 只在 stream 模式下有意义
1692
1754
  document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
1693
1755
  if (tab === 'tokens') renderTokenPage();
1694
1756
  }
1695
1757
 
1696
- let tokenTableVisible = false;
1697
- function toggleTokenTable() {
1698
- tokenTableVisible = !tokenTableVisible;
1699
- document.getElementById('token-table-wrap').style.display = tokenTableVisible ? 'block' : 'none';
1700
- document.getElementById('token-table-toggle').textContent = '明细表格 ' + (tokenTableVisible ? '' : '▸');
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');
1701
1773
  }
1702
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 ──
1703
1921
  function renderTokenPage() {
1704
- computeTokensFromContext();
1705
- const entries = Object.entries(contextData);
1706
- if (entries.length === 0) {
1707
- document.getElementById('token-overview-grid').innerHTML = '<div style="color:var(--dim);padding:8px">暂无 Token 数据</div>';
1708
- document.getElementById('token-agent-cards').innerHTML = '';
1709
- document.getElementById('token-table-body').innerHTML = '';
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 = '';
1710
1937
  return;
1711
1938
  }
1712
1939
 
1713
- // ── Overview card ──
1714
- const overviewGrid = document.getElementById('token-overview-grid');
1715
- const overviewItems = [
1716
- { label: 'Input Tokens', value: fmtTok(totalInput), pct: null },
1717
- { label: 'Output Tokens', value: fmtTok(totalOutput), pct: null },
1718
- { label: 'Cache Creation', value: fmtTok(totalCacheCreate), pct: null },
1719
- { label: 'Cache Read', value: fmtTok(totalCacheRead), pct: null },
1720
- { label: 'I/O Ratio', value: totalOutput > 0 ? (totalInput / totalOutput).toFixed(1) + ' : 1' : '—', pct: null },
1721
- ];
1722
- overviewGrid.innerHTML = overviewItems.map(it =>
1723
- `<div class="token-item"><span class="token-label">${it.label}</span><span class="token-value">${it.value}</span></div>`
1724
- ).join('');
1725
-
1726
- // ── Agent cards ──
1727
- const agentCardsEl = document.getElementById('token-agent-cards');
1728
- // Sort by lastActivity descending (active first)
1729
- const sorted = entries.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
1730
- agentCardsEl.innerHTML = sorted.map(([key, ctx]) => {
1731
- const [sid, agentId] = key.split(':');
1732
- const isMain = agentId === 'main' || !agentId.includes('-');
1733
- const icon = isMain ? '🗣' : '🤖';
1734
- const agentName = isMain ? 'Main' : agentId;
1735
- const active = ctx.lastActivity && (Date.now() - ctx.lastActivity < 180000);
1736
- const activeDot = active ? '<span class="token-active-dot">🟢</span>' : '<span class="token-active-dot">⚪</span>';
1737
-
1738
- const pct = ctx.contextWindow > 0 ? Math.round(ctx.inputTokens / ctx.contextWindow * 100) : 0;
1739
- const pctCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1740
- const barCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1741
- const barWidth = Math.min(pct, 100);
1742
-
1743
- const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) + ' : 1' : '—';
1744
-
1745
- return `<div class="token-card">
1746
- <div class="token-card-title">${icon} ${esc(agentName)} ${ctx.model ? '· ' + esc(ctx.model) : ''} ${activeDot}</div>
1747
- <div class="token-usage-line">
1748
- <span class="token-pct ${pctCls}">${pct}%</span>
1749
- <span class="token-ctx-info">${fmtTok(ctx.inputTokens)} / ${fmtTok(ctx.contextWindow)}</span>
1750
- </div>
1751
- <div class="token-bar"><div class="token-bar-fill ${barCls}" style="width:${barWidth}%"></div></div>
1752
- <div class="token-detail-row">
1753
- <span class="token-kv">Output: <b>${fmtTok(ctx.outputTokens)}</b></span>
1754
- <span class="token-kv">Cache+: <b>${fmtTok(ctx.cacheCreation)}</b></span>
1755
- <span class="token-kv">Cache Read: <b>${fmtTok(ctx.cacheRead)}</b></span>
1756
- <span class="token-kv">I/O: <b>${ioRatio}</b></span>
1757
- </div>
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>
1758
1954
  </div>`;
1759
- }).join('');
1760
-
1761
- // ── Detail table ──
1762
- const tbody = document.getElementById('token-table-body');
1763
- tbody.innerHTML = sorted.map(([key, ctx]) => {
1764
- const [sid, agentId] = key.split(':');
1765
- const agentName = (agentId === 'main' || !agentId.includes('-')) ? 'Main' : agentId;
1766
- const pct = ctx.contextWindow > 0 ? (ctx.inputTokens / ctx.contextWindow * 100).toFixed(1) + '%' : '—';
1767
- const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) : '';
1768
- return `<tr>
1769
- <td>${esc(agentName)}</td>
1770
- <td>${esc(ctx.model || '—')}</td>
1771
- <td>${ctx.inputTokens}</td>
1772
- <td>${ctx.outputTokens}</td>
1773
- <td>${ctx.cacheCreation}</td>
1774
- <td>${ctx.cacheRead}</td>
1775
- <td>${ctx.contextWindow}</td>
1776
- <td>${pct}</td>
1777
- <td>${ioRatio}</td>
1778
- </tr>`;
1779
- }).join('');
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;
1780
2080
  }
1781
2081
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1782
2082
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
@@ -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 };