@beastmode-develeap/beastmode 0.1.111 → 0.1.113

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.
@@ -15,7 +15,7 @@
15
15
  }
16
16
  </script>
17
17
  <!--BOARD_DATA-->
18
- <script>window.__BUILD_STAMP__ = "20260418-125234-6f01092";</script>
18
+ <script>window.__BUILD_STAMP__ = "20260418-125925-5c4a0ed";</script>
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -1632,6 +1632,91 @@ input[type="range"]::-webkit-slider-thumb {
1632
1632
  white-space: nowrap;
1633
1633
  }
1634
1634
 
1635
+ /* Cost badge on card */
1636
+ .card-badge.badge-cost {
1637
+ background: var(--success-subtle);
1638
+ color: var(--success);
1639
+ font-family: var(--font-mono);
1640
+ font-size: 10px;
1641
+ font-weight: 600;
1642
+ letter-spacing: 0.01em;
1643
+ }
1644
+
1645
+ /* Detail sidebar cost section */
1646
+ .detail-cost-section {
1647
+ padding: 12px 24px;
1648
+ border-bottom: 1px solid var(--border);
1649
+ }
1650
+ .detail-cost-title {
1651
+ margin: 0 0 10px 0;
1652
+ font-size: 13px;
1653
+ font-weight: 600;
1654
+ display: flex;
1655
+ align-items: center;
1656
+ gap: 6px;
1657
+ color: var(--text);
1658
+ }
1659
+ .cost-totals {
1660
+ display: grid;
1661
+ grid-template-columns: repeat(3, 1fr);
1662
+ gap: 8px;
1663
+ margin-bottom: 12px;
1664
+ }
1665
+ .cost-total-item {
1666
+ display: flex;
1667
+ flex-direction: column;
1668
+ gap: 2px;
1669
+ padding: 8px 10px;
1670
+ background: var(--bg-input);
1671
+ border-radius: var(--radius-xs);
1672
+ border: 1px solid var(--border-subtle);
1673
+ }
1674
+ .cost-total-label {
1675
+ font-size: 10px;
1676
+ font-weight: 500;
1677
+ color: var(--text-muted);
1678
+ text-transform: uppercase;
1679
+ letter-spacing: 0.04em;
1680
+ }
1681
+ .cost-total-value {
1682
+ font-size: 16px;
1683
+ font-weight: 600;
1684
+ font-family: var(--font-mono);
1685
+ color: var(--text);
1686
+ }
1687
+ .cost-value-usd {
1688
+ color: var(--success);
1689
+ }
1690
+ .cost-phase-table {
1691
+ width: 100%;
1692
+ border-collapse: collapse;
1693
+ font-size: 12px;
1694
+ }
1695
+ .cost-phase-table th {
1696
+ text-align: left;
1697
+ padding: 6px 8px;
1698
+ font-size: 10px;
1699
+ font-weight: 600;
1700
+ color: var(--text-muted);
1701
+ text-transform: uppercase;
1702
+ letter-spacing: 0.04em;
1703
+ border-bottom: 1px solid var(--border);
1704
+ }
1705
+ .cost-phase-table td {
1706
+ padding: 6px 8px;
1707
+ border-bottom: 1px solid var(--border-subtle);
1708
+ }
1709
+ .cost-phase-name {
1710
+ font-weight: 500;
1711
+ color: var(--text);
1712
+ text-transform: capitalize;
1713
+ }
1714
+ .cost-phase-value {
1715
+ font-family: var(--font-mono);
1716
+ color: var(--text-secondary);
1717
+ font-size: 11px;
1718
+ }
1719
+
1635
1720
  /* ================================================================
1636
1721
  EXTENSIONS / ITEMS LIST
1637
1722
  ================================================================ */
@@ -2562,6 +2647,36 @@ async function api(method, path, body) {
2562
2647
  return data;
2563
2648
  }
2564
2649
 
2650
+ // ================================================================
2651
+ // Cost / Token / Duration Formatters
2652
+ // ================================================================
2653
+
2654
+ function formatCost(usd) {
2655
+ if (usd === null || usd === undefined) return null;
2656
+ if (usd >= 100) return '$' + Math.round(usd);
2657
+ if (usd >= 10) return '$' + usd.toFixed(1);
2658
+ if (usd >= 0.01) return '$' + usd.toFixed(2);
2659
+ return '<$0.01';
2660
+ }
2661
+
2662
+ function formatTokens(n) {
2663
+ if (n === null || n === undefined || n === 0) return '0';
2664
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
2665
+ if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
2666
+ return String(n);
2667
+ }
2668
+
2669
+ function formatDuration(s) {
2670
+ if (!s || s <= 0) return '0s';
2671
+ if (s < 60) return Math.round(s) + 's';
2672
+ const m = Math.floor(s / 60);
2673
+ const sec = Math.round(s % 60);
2674
+ if (m < 60) return sec > 0 ? m + 'm ' + sec + 's' : m + 'm';
2675
+ const h = Math.floor(m / 60);
2676
+ const rm = m % 60;
2677
+ return rm > 0 ? h + 'h ' + rm + 'm' : h + 'h';
2678
+ }
2679
+
2565
2680
  // ================================================================
2566
2681
  // Router
2567
2682
  // ================================================================
@@ -3561,9 +3676,25 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3561
3676
  // that open in a new tab when clicked.
3562
3677
  const [attachments, setAttachments] = useState([]);
3563
3678
  const [loadingAttachments, setLoadingAttachments] = useState(true);
3679
+ const [costSummary, setCostSummary] = useState(null);
3680
+ const [loadingCost, setLoadingCost] = useState(true);
3564
3681
  const sidebarRef = useRef(null);
3565
3682
  const topCommentRef = useRef(null);
3566
3683
 
3684
+ // Cost summary fetch — keyed on item.id, refreshed alongside the
3685
+ // 10-second updates/attachments poll below. The api() helper only
3686
+ // auto-scopes /api/board/*, so append ?board=<proj> manually.
3687
+ // Treat record_count=0 as "no cost data" so the sidebar stays clean.
3688
+ const refreshCostSummary = useCallback(() => {
3689
+ if (!item) return;
3690
+ const proj = localStorage.getItem('beastmode-selected-project') || '';
3691
+ const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
3692
+ fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
3693
+ .then(r => r.ok ? r.json() : null)
3694
+ .then(data => setCostSummary(data && data.record_count > 0 ? data : null))
3695
+ .catch(() => setCostSummary(null));
3696
+ }, [item && item.id]);
3697
+
3567
3698
  const refreshUpdates = useCallback(() => {
3568
3699
  if (!item) return;
3569
3700
  api('GET', '/api/board/items/' + item.id + '/updates')
@@ -3602,9 +3733,18 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3602
3733
  })
3603
3734
  .catch(() => setAttachments([]))
3604
3735
  .finally(() => setLoadingAttachments(false));
3736
+ setLoadingCost(true);
3737
+ const proj = localStorage.getItem('beastmode-selected-project') || '';
3738
+ const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
3739
+ fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
3740
+ .then(r => r.ok ? r.json() : null)
3741
+ .then(data => setCostSummary(data && data.record_count > 0 ? data : null))
3742
+ .catch(() => setCostSummary(null))
3743
+ .finally(() => setLoadingCost(false));
3605
3744
  const interval = setInterval(() => {
3606
3745
  refreshUpdates();
3607
3746
  refreshAttachments();
3747
+ refreshCostSummary();
3608
3748
  }, 10000);
3609
3749
  return () => clearInterval(interval);
3610
3750
  }, [item && item.id]);
@@ -3734,6 +3874,59 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3734
3874
  <label>Parent Epic</label>
3735
3875
  <div class="detail-value">${item.parent_epic ? '#' + item.parent_epic : '\u2014'}</div>
3736
3876
  </div>
3877
+ ${loadingCost
3878
+ ? html`
3879
+ <div class="detail-cost-section" data-testid="cost-section">
3880
+ <h4 class="detail-cost-title">Cost</h4>
3881
+ <div class="loading-text">Loading costs...</div>
3882
+ </div>`
3883
+ : (costSummary && html`
3884
+ <div class="detail-cost-section" data-testid="cost-section">
3885
+ <h4 class="detail-cost-title">
3886
+ <span style="font-family:var(--font-mono);color:var(--success);">$</span>
3887
+ Cost Summary
3888
+ </h4>
3889
+ <div class="cost-totals">
3890
+ <div class="cost-total-item">
3891
+ <span class="cost-total-label">Total Cost</span>
3892
+ <span class="cost-total-value cost-value-usd">${formatCost(costSummary.total_cost_usd) || '$0.00'}</span>
3893
+ </div>
3894
+ <div class="cost-total-item">
3895
+ <span class="cost-total-label">Tokens</span>
3896
+ <span class="cost-total-value">${formatTokens((costSummary.total_input_tokens || 0) + (costSummary.total_output_tokens || 0))}</span>
3897
+ </div>
3898
+ <div class="cost-total-item">
3899
+ <span class="cost-total-label">Duration</span>
3900
+ <span class="cost-total-value">${formatDuration(costSummary.total_duration_seconds)}</span>
3901
+ </div>
3902
+ </div>
3903
+ ${costSummary.phases && Object.keys(costSummary.phases).length > 0 && html`
3904
+ <table class="cost-phase-table">
3905
+ <thead>
3906
+ <tr>
3907
+ <th>Phase</th>
3908
+ <th>Cost</th>
3909
+ <th>Tokens</th>
3910
+ <th>Duration</th>
3911
+ </tr>
3912
+ </thead>
3913
+ <tbody>
3914
+ ${Object.values(costSummary.phases)
3915
+ .slice()
3916
+ .sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0))
3917
+ .map((phase) => html`
3918
+ <tr key=${phase.phase}>
3919
+ <td class="cost-phase-name">${phase.phase}</td>
3920
+ <td class="cost-phase-value">${formatCost(phase.cost_usd) || '$0.00'}</td>
3921
+ <td class="cost-phase-value">${formatTokens((phase.input_tokens || 0) + (phase.output_tokens || 0))}</td>
3922
+ <td class="cost-phase-value">${formatDuration(phase.duration_seconds)}</td>
3923
+ </tr>
3924
+ `)}
3925
+ </tbody>
3926
+ </table>
3927
+ `}
3928
+ </div>
3929
+ `)}
3737
3930
  ${(loadingAttachments || attachments.length > 0) && html`
3738
3931
  <div style="padding:12px 24px 0;">
3739
3932
  <h4 style="margin:0 0 8px 0;font-size:13px;font-weight:600;">
@@ -3919,6 +4112,7 @@ function PipelineView({
3919
4112
  columnSorts,
3920
4113
  cycleSort,
3921
4114
  sortColumnItems,
4115
+ costsByItem,
3922
4116
  }) {
3923
4117
  const [collapseKey, setCollapseKey] = useState(0);
3924
4118
  const [, setEpicCollapseKey] = useState(0);
@@ -3943,6 +4137,11 @@ function PipelineView({
3943
4137
  ${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
3944
4138
  ${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
3945
4139
  ${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
4140
+ ${(() => {
4141
+ const cost = costsByItem && costsByItem[String(item.id)];
4142
+ const label = cost ? formatCost(cost.total_cost_usd) : null;
4143
+ return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
4144
+ })()}
3946
4145
  <span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
3947
4146
  </div>
3948
4147
  </div>
@@ -4057,6 +4256,7 @@ function BoardPage({ selectedProject }) {
4057
4256
  const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
4058
4257
  const [columnSorts, setColumnSorts] = useState({});
4059
4258
  const [epicCollapseKey, setEpicCollapseKey] = useState(0);
4259
+ const [costsByItem, setCostsByItem] = useState({});
4060
4260
  const viewMode = (() => {
4061
4261
  try {
4062
4262
  const params = new URLSearchParams(window.location.search);
@@ -4073,20 +4273,70 @@ function BoardPage({ selectedProject }) {
4073
4273
  .finally(() => setLoading(false));
4074
4274
  }, [selectedProject]);
4075
4275
 
4276
+ // Cost endpoints are /api/costs/* and /api/items/*/costs/summary —
4277
+ // the api() helper only auto-scopes /api/board/*, so we manually
4278
+ // append ?board=<proj> here. Errors are swallowed: if the cost
4279
+ // batch endpoint is unreachable, cards render without badges.
4280
+ const fetchCosts = useCallback(() => {
4281
+ const proj = localStorage.getItem('beastmode-selected-project') || '';
4282
+ const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
4283
+ fetch('/api/costs/by-items' + boardParam)
4284
+ .then(r => r.ok ? r.json() : {})
4285
+ .then(data => setCostsByItem(data || {}))
4286
+ .catch(() => {});
4287
+ }, [selectedProject]);
4288
+
4076
4289
  // Re-fetch whenever the selected project changes — each project
4077
4290
  // has its own SQLite board on the server.
4078
4291
  // Also poll every 10s for live updates from the daemon.
4079
4292
  useEffect(() => {
4080
4293
  fetchItems();
4294
+ fetchCosts();
4081
4295
  const interval = setInterval(() => {
4082
4296
  // Silent refresh — don't show loading spinner on polls
4083
4297
  api('GET', '/api/board/items')
4084
4298
  .then(data => setItems(data.items || []))
4085
4299
  .catch(() => {});
4300
+ fetchCosts();
4086
4301
  }, 10000);
4087
4302
  return () => clearInterval(interval);
4088
4303
  }, [selectedProject]);
4089
4304
 
4305
+ // WebSocket listener for real-time cost updates (T4).
4306
+ // On any `cost_recorded` or `costs_cleared` event, re-fetch the
4307
+ // full batch — the endpoint is a single GROUP BY query and is
4308
+ // cheap. Auto-reconnect after 5s on disconnect.
4309
+ useEffect(() => {
4310
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
4311
+ const wsUrl = proto + '//' + location.host + '/ws';
4312
+ let ws;
4313
+ let reconnectTimer;
4314
+
4315
+ function connect() {
4316
+ ws = new WebSocket(wsUrl);
4317
+ ws.onmessage = (event) => {
4318
+ try {
4319
+ const msg = JSON.parse(event.data);
4320
+ if (msg.type === 'cost_recorded' || msg.type === 'costs_cleared') {
4321
+ fetchCosts();
4322
+ }
4323
+ } catch {}
4324
+ };
4325
+ ws.onclose = () => {
4326
+ reconnectTimer = setTimeout(connect, 5000);
4327
+ };
4328
+ ws.onerror = () => {
4329
+ ws.close();
4330
+ };
4331
+ }
4332
+ connect();
4333
+
4334
+ return () => {
4335
+ clearTimeout(reconnectTimer);
4336
+ if (ws) ws.close();
4337
+ };
4338
+ }, []);
4339
+
4090
4340
  // Sync scroll thumb position with kanban scroll + drag support
4091
4341
  useEffect(() => {
4092
4342
  const kanban = document.querySelector('.kanban');
@@ -4420,6 +4670,7 @@ function BoardPage({ selectedProject }) {
4420
4670
  columnSorts=${columnSorts}
4421
4671
  cycleSort=${cycleSort}
4422
4672
  sortColumnItems=${sortColumnItems}
4673
+ costsByItem=${costsByItem}
4423
4674
  />`
4424
4675
  : html`
4425
4676
  <div class="kanban-wrapper">
@@ -4485,6 +4736,11 @@ function BoardPage({ selectedProject }) {
4485
4736
  ${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
4486
4737
  ${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
4487
4738
  ${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
4739
+ ${(() => {
4740
+ const cost = costsByItem && costsByItem[String(item.id)];
4741
+ const label = cost ? formatCost(cost.total_cost_usd) : null;
4742
+ return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
4743
+ })()}
4488
4744
  <span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
4489
4745
  </div>
4490
4746
  </div>
@@ -1 +1 @@
1
- 20260418-125234-6f01092
1
+ 20260418-125925-5c4a0ed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beastmode-develeap/beastmode",
3
- "version": "0.1.111",
3
+ "version": "0.1.113",
4
4
  "description": "BeastMode Dark Factory — turn intent into verified software",
5
5
  "type": "module",
6
6
  "bin": {