codedash-app 3.1.0 → 3.2.0

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": "codedash-app",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/data.js CHANGED
@@ -589,6 +589,86 @@ function getSessionReplay(sessionId, project) {
589
589
  };
590
590
  }
591
591
 
592
+ // ── Pricing per model (per token, April 2026) ─────────────
593
+
594
+ const MODEL_PRICING = {
595
+ 'claude-opus-4-6': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 },
596
+ 'claude-opus-4-5': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 },
597
+ 'claude-sonnet-4-6': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 },
598
+ 'claude-sonnet-4-5': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 },
599
+ 'claude-haiku-4-5': { input: 1.00 / 1e6, output: 5.00 / 1e6, cache_read: 0.10 / 1e6, cache_create: 1.25 / 1e6 },
600
+ 'codex-mini-latest': { input: 1.50 / 1e6, output: 6.00 / 1e6, cache_read: 0.375 / 1e6, cache_create: 1.875 / 1e6 },
601
+ 'gpt-5': { input: 1.25 / 1e6, output: 10.00 / 1e6, cache_read: 0.625 / 1e6, cache_create: 1.25 / 1e6 },
602
+ };
603
+
604
+ function getModelPricing(model) {
605
+ if (!model) return MODEL_PRICING['claude-sonnet-4-6']; // default
606
+ for (const key in MODEL_PRICING) {
607
+ if (model.includes(key) || model.startsWith(key)) return MODEL_PRICING[key];
608
+ }
609
+ // Fallback: try partial match
610
+ if (model.includes('opus')) return MODEL_PRICING['claude-opus-4-6'];
611
+ if (model.includes('haiku')) return MODEL_PRICING['claude-haiku-4-5'];
612
+ if (model.includes('sonnet')) return MODEL_PRICING['claude-sonnet-4-6'];
613
+ if (model.includes('codex')) return MODEL_PRICING['codex-mini-latest'];
614
+ return MODEL_PRICING['claude-sonnet-4-6'];
615
+ }
616
+
617
+ // ── Compute real cost from session file token usage ────────
618
+
619
+ function computeSessionCost(sessionId, project) {
620
+ const found = findSessionFile(sessionId, project);
621
+ if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' };
622
+
623
+ let totalCost = 0;
624
+ let totalInput = 0;
625
+ let totalOutput = 0;
626
+ let model = '';
627
+
628
+ try {
629
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
630
+ for (const line of lines) {
631
+ try {
632
+ const entry = JSON.parse(line);
633
+ if (found.format === 'claude' && entry.type === 'assistant') {
634
+ const msg = entry.message || {};
635
+ if (!model && msg.model) model = msg.model;
636
+ const u = msg.usage;
637
+ if (!u) continue;
638
+
639
+ const pricing = getModelPricing(msg.model || model);
640
+ const inp = u.input_tokens || 0;
641
+ const cacheCreate = u.cache_creation_input_tokens || 0;
642
+ const cacheRead = u.cache_read_input_tokens || 0;
643
+ const out = u.output_tokens || 0;
644
+
645
+ totalInput += inp + cacheCreate + cacheRead;
646
+ totalOutput += out;
647
+ totalCost += inp * pricing.input
648
+ + cacheCreate * pricing.cache_create
649
+ + cacheRead * pricing.cache_read
650
+ + out * pricing.output;
651
+ }
652
+ // Codex: estimate from file size (no token usage in session files)
653
+ } catch {}
654
+ }
655
+ } catch {}
656
+
657
+ // Fallback for Codex or sessions without usage data
658
+ if (totalCost === 0 && found.format === 'codex') {
659
+ try {
660
+ const size = fs.statSync(found.file).size;
661
+ const tokens = size / 4;
662
+ const pricing = MODEL_PRICING['codex-mini-latest'];
663
+ totalInput = Math.round(tokens * 0.3);
664
+ totalOutput = Math.round(tokens * 0.7);
665
+ totalCost = totalInput * pricing.input + totalOutput * pricing.output;
666
+ } catch {}
667
+ }
668
+
669
+ return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model };
670
+ }
671
+
592
672
  // ── Cost analytics ────────────────────────────────────────
593
673
 
594
674
  function getCostAnalytics(sessions) {
@@ -600,9 +680,10 @@ function getCostAnalytics(sessions) {
600
680
  const sessionCosts = [];
601
681
 
602
682
  for (const s of sessions) {
603
- if (!s.file_size) continue;
604
- const tokens = s.file_size / 4;
605
- const cost = tokens * 0.000015 * 0.3 + tokens * 0.000075 * 0.7;
683
+ const costData = computeSessionCost(s.id, s.project);
684
+ const cost = costData.cost;
685
+ const tokens = costData.inputTokens + costData.outputTokens;
686
+ if (cost === 0 && tokens === 0) continue;
606
687
  totalCost += cost;
607
688
  totalTokens += tokens;
608
689
 
@@ -746,6 +827,8 @@ module.exports = {
746
827
  getActiveSessions,
747
828
  getSessionReplay,
748
829
  getCostAnalytics,
830
+ computeSessionCost,
831
+ MODEL_PRICING,
749
832
  CLAUDE_DIR,
750
833
  CODEX_DIR,
751
834
  HISTORY_FILE,
@@ -91,9 +91,16 @@ function formatBytes(bytes) {
91
91
 
92
92
  function estimateCost(fileSize) {
93
93
  if (!fileSize) return 0;
94
- const tokens = fileSize / 4;
95
- // Rough estimate: 30% input tokens, 70% output tokens
96
- return tokens * 0.000015 * 0.3 + tokens * 0.000075 * 0.7;
94
+ var tokens = fileSize / 4;
95
+ // Quick card badge estimate (Sonnet 4.6: $3/M in, $15/M out)
96
+ return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6);
97
+ }
98
+
99
+ async function loadRealCost(sessionId, project) {
100
+ try {
101
+ var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project));
102
+ return await resp.json();
103
+ } catch (e) { return null; }
97
104
  }
98
105
 
99
106
  // ── Tag system ─────────────────────────────────────────────────
@@ -726,7 +733,7 @@ function render() {
726
733
  }
727
734
 
728
735
  if (currentView === 'running') {
729
- renderRunning(content);
736
+ renderRunning(content, sessions);
730
737
  return;
731
738
  }
732
739
 
@@ -1050,8 +1057,9 @@ async function openDetail(s) {
1050
1057
  infoHtml += '<div class="detail-row"><span class="detail-label">Messages</span><span>' + (s.detail_messages || s.messages || 0) + '</span></div>';
1051
1058
  infoHtml += '<div class="detail-row"><span class="detail-label">File size</span><span>' + formatBytes(s.file_size) + '</span></div>';
1052
1059
  if (costStr) {
1053
- infoHtml += '<div class="detail-row"><span class="detail-label">Est. cost</span><span class="cost-badge">' + costStr + '</span></div>';
1060
+ infoHtml += '<div class="detail-row"><span class="detail-label">Est. cost</span><span class="cost-badge" id="detail-cost">' + costStr + '</span></div>';
1054
1061
  }
1062
+ infoHtml += '<div class="detail-row" id="detail-real-cost" style="display:none"><span class="detail-label">Real cost</span><span></span></div>';
1055
1063
  // Tags
1056
1064
  infoHtml += '<div class="detail-row"><span class="detail-label">Tags</span><span class="card-tags">';
1057
1065
  sessionTags.forEach(function(t) {
@@ -1110,6 +1118,23 @@ async function openDetail(s) {
1110
1118
  body.querySelector('.detail-messages').innerHTML = '<div class="empty-state">No detail file available for this session.</div>';
1111
1119
  }
1112
1120
 
1121
+ // Load real cost
1122
+ loadRealCost(s.id, s.project || '').then(function(costData) {
1123
+ if (!costData || !costData.cost) return;
1124
+ var row = document.getElementById('detail-real-cost');
1125
+ if (row) {
1126
+ row.style.display = '';
1127
+ row.querySelector('span:last-child').innerHTML =
1128
+ '<span class="cost-badge" style="background:rgba(74,222,128,0.2);color:var(--accent-green)">$' + costData.cost.toFixed(2) + '</span>' +
1129
+ ' <span style="font-size:11px;color:var(--text-muted)">' +
1130
+ formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' +
1131
+ (costData.model ? ' (' + costData.model + ')' : '') + '</span>';
1132
+ }
1133
+ // Update estimated badge to show it was estimated
1134
+ var estBadge = document.getElementById('detail-cost');
1135
+ if (estBadge) estBadge.style.opacity = '0.5';
1136
+ });
1137
+
1113
1138
  // Load git commits
1114
1139
  if (s.project) {
1115
1140
  var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts);
@@ -1440,7 +1465,7 @@ document.addEventListener('keydown', function(e) {
1440
1465
 
1441
1466
  // ── Running Sessions View ──────────────────────────────────────
1442
1467
 
1443
- function renderRunning(container) {
1468
+ function renderRunning(container, sessions) {
1444
1469
  var activeIds = Object.keys(activeSessions);
1445
1470
 
1446
1471
  if (activeIds.length === 0) {
@@ -1448,8 +1473,10 @@ function renderRunning(container) {
1448
1473
  return;
1449
1474
  }
1450
1475
 
1476
+ // Running cards at top
1451
1477
  var html = '<div class="running-container">';
1452
- html += '<h2 class="heatmap-title">Running Sessions</h2>';
1478
+ html += '<h2 class="heatmap-title">Running Sessions (' + activeIds.length + ')</h2>';
1479
+ html += '<div class="running-grid">';
1453
1480
 
1454
1481
  activeIds.forEach(function(sid) {
1455
1482
  var a = activeSessions[sid];
@@ -1466,7 +1493,6 @@ function renderRunning(container) {
1466
1493
  html += '<span class="running-tool">' + escHtml(a.entrypoint || a.kind || 'claude') + '</span>';
1467
1494
  html += '</div>';
1468
1495
 
1469
- // Stats row
1470
1496
  html += '<div class="running-stats">';
1471
1497
  html += '<div class="running-stat"><span class="running-stat-val">' + a.cpu.toFixed(1) + '%</span><span class="running-stat-label">CPU</span></div>';
1472
1498
  html += '<div class="running-stat"><span class="running-stat-val">' + a.memoryMB + 'MB</span><span class="running-stat-label">Memory</span></div>';
@@ -1476,22 +1502,32 @@ function renderRunning(container) {
1476
1502
  }
1477
1503
  html += '</div>';
1478
1504
 
1479
- // Message preview
1480
1505
  if (s && s.first_message) {
1481
1506
  html += '<div class="running-msg">' + escHtml(s.first_message.slice(0, 150)) + '</div>';
1482
1507
  }
1483
1508
 
1484
- // Action buttons
1485
1509
  html += '<div class="running-actions">';
1486
- html += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + sid + '\')">Focus Terminal</button>';
1510
+ html += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + sid + '\')">Focus</button>';
1487
1511
  if (s) {
1488
1512
  html += '<button class="launch-btn btn-secondary" onclick="var ss=allSessions.find(function(x){return x.id===\'' + sid + '\'});if(ss)openDetail(ss);">Details</button>';
1513
+ html += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + sid + '\',\'' + escHtml((s.project || '').replace(/'/g, "\\'")) + '\')">Replay</button>';
1489
1514
  }
1490
1515
  html += '</div>';
1491
-
1492
1516
  html += '</div>';
1493
1517
  });
1494
1518
 
1519
+ html += '</div>';
1520
+
1521
+ // Also show recent non-active sessions below
1522
+ var recentInactive = sessions.filter(function(s) { return !activeSessions[s.id]; }).slice(0, 6);
1523
+ if (recentInactive.length > 0) {
1524
+ html += '<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">Recently Inactive</h3>';
1525
+ html += '<div class="grid-view">';
1526
+ var idx = 0;
1527
+ recentInactive.forEach(function(s) { html += renderCard(s, idx++); });
1528
+ html += '</div>';
1529
+ }
1530
+
1495
1531
  html += '</div>';
1496
1532
  container.innerHTML = html;
1497
1533
  }
@@ -1586,6 +1586,12 @@ body {
1586
1586
 
1587
1587
  .running-container { padding: 20px; }
1588
1588
 
1589
+ .running-grid {
1590
+ display: grid;
1591
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
1592
+ gap: 12px;
1593
+ }
1594
+
1589
1595
  .running-card {
1590
1596
  background: var(--bg-card);
1591
1597
  border: 1px solid var(--border);
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const { URL } = require('url');
5
5
  const { exec } = require('child_process');
6
- const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics } = require('./data');
6
+ const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost } = require('./data');
7
7
  const { detectTerminals, openInTerminal, focusTerminalByPid } = require('./terminals');
8
8
  const { getHTML } = require('./html');
9
9
 
@@ -140,6 +140,14 @@ function startServer(port, openBrowser = true) {
140
140
  json(res, results);
141
141
  }
142
142
 
143
+ // ── Session cost ──────────────────────
144
+ else if (req.method === 'GET' && pathname.startsWith('/api/cost/')) {
145
+ const sessionId = pathname.split('/').pop();
146
+ const project = parsed.searchParams.get('project') || '';
147
+ const data = computeSessionCost(sessionId, project);
148
+ json(res, data);
149
+ }
150
+
143
151
  // ── Session replay ─────────────────────
144
152
  else if (req.method === 'GET' && pathname.startsWith('/api/replay/')) {
145
153
  const sessionId = pathname.split('/').pop();