@beastmode-develeap/beastmode 0.1.110 → 0.1.112

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-082837-7de1d16";</script>
18
+ <script>window.__BUILD_STAMP__ = "20260418-125802-c140b02";</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
  ================================================================ */
@@ -2149,6 +2234,115 @@ input[type="range"]::-webkit-slider-thumb {
2149
2234
  gap: 8px;
2150
2235
  }
2151
2236
 
2237
+ /* ================================================================
2238
+ COSTS PAGE
2239
+ ================================================================ */
2240
+
2241
+ .costs-table-wrapper {
2242
+ overflow-x: auto;
2243
+ -webkit-overflow-scrolling: touch;
2244
+ }
2245
+
2246
+ .costs-table {
2247
+ width: 100%;
2248
+ border-collapse: collapse;
2249
+ font-family: var(--font-sans);
2250
+ font-size: 13px;
2251
+ }
2252
+
2253
+ .costs-th {
2254
+ text-align: left;
2255
+ padding: 10px 12px;
2256
+ font-size: 11px;
2257
+ font-weight: 600;
2258
+ text-transform: uppercase;
2259
+ letter-spacing: 0.5px;
2260
+ color: var(--text-muted);
2261
+ border-bottom: 1px solid var(--border);
2262
+ cursor: pointer;
2263
+ user-select: none;
2264
+ white-space: nowrap;
2265
+ }
2266
+
2267
+ .costs-th:hover {
2268
+ color: var(--accent);
2269
+ }
2270
+
2271
+ .costs-tr {
2272
+ cursor: pointer;
2273
+ transition: background 0.15s ease;
2274
+ }
2275
+
2276
+ .costs-tr:hover {
2277
+ background: var(--bg-card-hover);
2278
+ }
2279
+
2280
+ .costs-td {
2281
+ padding: 10px 12px;
2282
+ border-bottom: 1px solid var(--border-subtle);
2283
+ color: var(--text);
2284
+ vertical-align: middle;
2285
+ }
2286
+
2287
+ .costs-td-mono {
2288
+ font-family: var(--font-mono);
2289
+ font-size: 13px;
2290
+ }
2291
+
2292
+ .costs-td-name {
2293
+ font-weight: 500;
2294
+ max-width: 300px;
2295
+ overflow: hidden;
2296
+ text-overflow: ellipsis;
2297
+ white-space: nowrap;
2298
+ }
2299
+
2300
+ .costs-phase-chart {
2301
+ padding: 16px 0;
2302
+ }
2303
+
2304
+ .costs-phase-row {
2305
+ display: flex;
2306
+ align-items: center;
2307
+ gap: 12px;
2308
+ padding: 6px 16px;
2309
+ }
2310
+
2311
+ .costs-phase-label {
2312
+ width: 100px;
2313
+ font-size: 13px;
2314
+ font-weight: 500;
2315
+ color: var(--text-secondary);
2316
+ text-transform: capitalize;
2317
+ flex-shrink: 0;
2318
+ }
2319
+
2320
+ .costs-phase-bar-bg {
2321
+ flex: 1;
2322
+ height: 24px;
2323
+ background: var(--accent-subtle);
2324
+ border-radius: var(--radius-xs);
2325
+ overflow: hidden;
2326
+ }
2327
+
2328
+ .costs-phase-bar-fill {
2329
+ height: 100%;
2330
+ background: var(--accent);
2331
+ border-radius: var(--radius-xs);
2332
+ transition: width 0.4s ease;
2333
+ min-width: 2px;
2334
+ }
2335
+
2336
+ .costs-phase-amount {
2337
+ width: 80px;
2338
+ text-align: right;
2339
+ font-family: var(--font-mono);
2340
+ font-size: 13px;
2341
+ font-weight: 500;
2342
+ color: var(--text);
2343
+ flex-shrink: 0;
2344
+ }
2345
+
2152
2346
  /* ================================================================
2153
2347
  RESPONSIVE
2154
2348
  ================================================================ */
@@ -2176,6 +2370,10 @@ input[type="range"]::-webkit-slider-thumb {
2176
2370
  /* Detail sidebar: full-width on tablets */
2177
2371
  .detail-sidebar { width: 100vw !important; max-width: 100vw; }
2178
2372
  .detail-resize-handle { display: none; }
2373
+ /* Costs page */
2374
+ .costs-phase-label { width: 70px; font-size: 12px; }
2375
+ .costs-phase-amount { width: 60px; font-size: 12px; }
2376
+ .costs-td-name { max-width: 150px; }
2179
2377
  }
2180
2378
 
2181
2379
  @media (max-width: 600px) {
@@ -2192,6 +2390,9 @@ input[type="range"]::-webkit-slider-thumb {
2192
2390
  .detail-sidebar { padding: 16px; }
2193
2391
  .detail-header h3 { font-size: 14px; }
2194
2392
  .update-body { font-size: 12px; }
2393
+ /* Costs page */
2394
+ .costs-table { font-size: 12px; }
2395
+ .costs-th, .costs-td { padding: 8px 6px; }
2195
2396
  }
2196
2397
 
2197
2398
  /* ================================================================
@@ -2446,6 +2647,36 @@ async function api(method, path, body) {
2446
2647
  return data;
2447
2648
  }
2448
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
+
2449
2680
  // ================================================================
2450
2681
  // Router
2451
2682
  // ================================================================
@@ -2516,6 +2747,7 @@ function Icon({ name, size = 18, className = '' }) {
2516
2747
  lightbulb: html`<path d="M8 1a5 5 0 00-3 9c.5.6.8 1.2 1 1.8V13h4v-1.2c.2-.6.5-1.2 1-1.8A5 5 0 008 1z"/><line x1="6" y1="13.5" x2="10" y2="13.5"/><line x1="6.5" y1="15" x2="9.5" y2="15"/>`,
2517
2748
  help: html`<circle cx="8" cy="8" r="6.5"/><path d="M6 6.5a2 2 0 013.7 1c0 1.5-1.7 1.5-1.7 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor" stroke="none"/>`,
2518
2749
  filter: html`<polygon points="1.5,2 14.5,2 9.5,8.5 9.5,13 6.5,14.5 6.5,8.5"/>`,
2750
+ costs: html`<circle cx="8" cy="8" r="6.5"/><path d="M8 3.5v1m0 7v1M6 10.5c0 .8.9 1.5 2 1.5s2-.7 2-1.5S9.1 9 8 9s-2-.7-2-1.5S6.9 6 8 6s2 .7 2 1.5"/>`,
2519
2751
  };
2520
2752
 
2521
2753
  return html`
@@ -2606,6 +2838,7 @@ function Sidebar({ currentHash, factoryName, theme, onToggleTheme, selectedProje
2606
2838
  ...(selectedProject !== 'all' ? [{ path: '#/board', icon: 'board', label: 'Board' }] : []),
2607
2839
  ...(selectedProject !== 'all' ? [{ path: '#/strategy', icon: 'strategy', label: 'Strategy' }] : []),
2608
2840
  { path: '#/runs', icon: 'runs', label: 'Runs' },
2841
+ { path: '#/costs', icon: 'costs', label: 'Costs' },
2609
2842
  { path: '#/learnings', icon: 'lightbulb', label: 'Learnings' },
2610
2843
  { path: '#/projects', icon: 'projects', label: 'Projects' },
2611
2844
  { path: '#/extensions', icon: 'extensions', label: 'Extensions' },
@@ -3443,9 +3676,25 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3443
3676
  // that open in a new tab when clicked.
3444
3677
  const [attachments, setAttachments] = useState([]);
3445
3678
  const [loadingAttachments, setLoadingAttachments] = useState(true);
3679
+ const [costSummary, setCostSummary] = useState(null);
3680
+ const [loadingCost, setLoadingCost] = useState(true);
3446
3681
  const sidebarRef = useRef(null);
3447
3682
  const topCommentRef = useRef(null);
3448
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
+
3449
3698
  const refreshUpdates = useCallback(() => {
3450
3699
  if (!item) return;
3451
3700
  api('GET', '/api/board/items/' + item.id + '/updates')
@@ -3484,9 +3733,18 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3484
3733
  })
3485
3734
  .catch(() => setAttachments([]))
3486
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));
3487
3744
  const interval = setInterval(() => {
3488
3745
  refreshUpdates();
3489
3746
  refreshAttachments();
3747
+ refreshCostSummary();
3490
3748
  }, 10000);
3491
3749
  return () => clearInterval(interval);
3492
3750
  }, [item && item.id]);
@@ -3616,6 +3874,59 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
3616
3874
  <label>Parent Epic</label>
3617
3875
  <div class="detail-value">${item.parent_epic ? '#' + item.parent_epic : '\u2014'}</div>
3618
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
+ `)}
3619
3930
  ${(loadingAttachments || attachments.length > 0) && html`
3620
3931
  <div style="padding:12px 24px 0;">
3621
3932
  <h4 style="margin:0 0 8px 0;font-size:13px;font-weight:600;">
@@ -3801,6 +4112,7 @@ function PipelineView({
3801
4112
  columnSorts,
3802
4113
  cycleSort,
3803
4114
  sortColumnItems,
4115
+ costsByItem,
3804
4116
  }) {
3805
4117
  const [collapseKey, setCollapseKey] = useState(0);
3806
4118
  const [, setEpicCollapseKey] = useState(0);
@@ -3825,6 +4137,11 @@ function PipelineView({
3825
4137
  ${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
3826
4138
  ${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
3827
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
+ })()}
3828
4145
  <span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
3829
4146
  </div>
3830
4147
  </div>
@@ -3939,6 +4256,7 @@ function BoardPage({ selectedProject }) {
3939
4256
  const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
3940
4257
  const [columnSorts, setColumnSorts] = useState({});
3941
4258
  const [epicCollapseKey, setEpicCollapseKey] = useState(0);
4259
+ const [costsByItem, setCostsByItem] = useState({});
3942
4260
  const viewMode = (() => {
3943
4261
  try {
3944
4262
  const params = new URLSearchParams(window.location.search);
@@ -3955,20 +4273,70 @@ function BoardPage({ selectedProject }) {
3955
4273
  .finally(() => setLoading(false));
3956
4274
  }, [selectedProject]);
3957
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
+
3958
4289
  // Re-fetch whenever the selected project changes — each project
3959
4290
  // has its own SQLite board on the server.
3960
4291
  // Also poll every 10s for live updates from the daemon.
3961
4292
  useEffect(() => {
3962
4293
  fetchItems();
4294
+ fetchCosts();
3963
4295
  const interval = setInterval(() => {
3964
4296
  // Silent refresh — don't show loading spinner on polls
3965
4297
  api('GET', '/api/board/items')
3966
4298
  .then(data => setItems(data.items || []))
3967
4299
  .catch(() => {});
4300
+ fetchCosts();
3968
4301
  }, 10000);
3969
4302
  return () => clearInterval(interval);
3970
4303
  }, [selectedProject]);
3971
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
+
3972
4340
  // Sync scroll thumb position with kanban scroll + drag support
3973
4341
  useEffect(() => {
3974
4342
  const kanban = document.querySelector('.kanban');
@@ -4302,6 +4670,7 @@ function BoardPage({ selectedProject }) {
4302
4670
  columnSorts=${columnSorts}
4303
4671
  cycleSort=${cycleSort}
4304
4672
  sortColumnItems=${sortColumnItems}
4673
+ costsByItem=${costsByItem}
4305
4674
  />`
4306
4675
  : html`
4307
4676
  <div class="kanban-wrapper">
@@ -4367,6 +4736,11 @@ function BoardPage({ selectedProject }) {
4367
4736
  ${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
4368
4737
  ${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
4369
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
+ })()}
4370
4744
  <span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
4371
4745
  </div>
4372
4746
  </div>
@@ -6895,6 +7269,216 @@ function StrategyPage({ selectedProject }) {
6895
7269
  `;
6896
7270
  }
6897
7271
 
7272
+ // ================================================================
7273
+ // Costs Page
7274
+ // ================================================================
7275
+
7276
+ function CostsPage({ selectedProject }) {
7277
+ const [costsByItems, setCostsByItems] = useState({});
7278
+ const [boardItems, setBoardItems] = useState([]);
7279
+ const [phaseData, setPhaseData] = useState([]);
7280
+ const [loading, setLoading] = useState(true);
7281
+ const [error, setError] = useState(null);
7282
+ const [sortCol, setSortCol] = useState('cost');
7283
+ const [sortDir, setSortDir] = useState('desc');
7284
+
7285
+ useEffect(() => {
7286
+ let cancelled = false;
7287
+
7288
+ async function fetchCosts() {
7289
+ try {
7290
+ const costQs = (selectedProject && selectedProject !== 'all')
7291
+ ? '?board=' + encodeURIComponent(selectedProject)
7292
+ : '';
7293
+
7294
+ const [costsData, boardData] = await Promise.all([
7295
+ api('GET', '/api/costs/by-items' + costQs),
7296
+ api('GET', '/api/board/items'),
7297
+ ]);
7298
+
7299
+ if (cancelled) return;
7300
+ setCostsByItems(costsData || {});
7301
+ setBoardItems((boardData && boardData.items) || []);
7302
+
7303
+ const itemIds = Object.keys(costsData || {});
7304
+ if (itemIds.length > 0) {
7305
+ const phaseMap = {};
7306
+ const summaryResults = await Promise.allSettled(
7307
+ itemIds.map(id => {
7308
+ const qs = (selectedProject && selectedProject !== 'all')
7309
+ ? '?board=' + encodeURIComponent(selectedProject)
7310
+ : '';
7311
+ return api('GET', '/api/items/' + id + '/costs/summary' + qs);
7312
+ })
7313
+ );
7314
+ summaryResults.forEach(result => {
7315
+ if (result.status === 'fulfilled' && result.value && result.value.phases) {
7316
+ for (const [phaseName, phaseInfo] of Object.entries(result.value.phases)) {
7317
+ phaseMap[phaseName] = (phaseMap[phaseName] || 0) + (phaseInfo.cost_usd || 0);
7318
+ }
7319
+ }
7320
+ });
7321
+ const sorted = Object.entries(phaseMap)
7322
+ .map(([phase, cost]) => ({ phase, cost }))
7323
+ .sort((a, b) => b.cost - a.cost);
7324
+ if (!cancelled) setPhaseData(sorted);
7325
+ }
7326
+ } catch (e) {
7327
+ if (!cancelled) setError(e.message || 'Failed to load cost data');
7328
+ } finally {
7329
+ if (!cancelled) setLoading(false);
7330
+ }
7331
+ }
7332
+
7333
+ setLoading(true);
7334
+ setError(null);
7335
+ fetchCosts();
7336
+ const interval = setInterval(fetchCosts, 30000);
7337
+ return () => { cancelled = true; clearInterval(interval); };
7338
+ }, [selectedProject]);
7339
+
7340
+ function formatTokens(n) {
7341
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
7342
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
7343
+ return String(n);
7344
+ }
7345
+
7346
+ function handleSort(col) {
7347
+ if (sortCol === col) {
7348
+ setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
7349
+ } else {
7350
+ setSortCol(col);
7351
+ setSortDir(col === 'name' || col === 'status' ? 'asc' : 'desc');
7352
+ }
7353
+ }
7354
+
7355
+ if (loading) return html`<${SkeletonLoader} type="stats" />`;
7356
+ if (error) return html`<div class="page-content"><div class="error-msg">${error}</div></div>`;
7357
+
7358
+ const mergedRows = Object.entries(costsByItems).map(([itemId, costs]) => {
7359
+ const item = boardItems.find(i => String(i.id) === String(itemId));
7360
+ return {
7361
+ id: itemId,
7362
+ name: item ? item.name : 'Item #' + itemId,
7363
+ status: item ? item.status : 'Unknown',
7364
+ totalTokens: (costs.total_input_tokens || 0) + (costs.total_output_tokens || 0),
7365
+ costUsd: costs.total_cost_usd || 0,
7366
+ records: costs.record_count || 0,
7367
+ };
7368
+ });
7369
+
7370
+ if (mergedRows.length === 0) {
7371
+ return html`<div class="page-content">
7372
+ <${EmptyState} icon="costs" title="No Cost Data" description="Cost data will appear here as tasks are processed by the pipeline." />
7373
+ </div>`;
7374
+ }
7375
+
7376
+ const totalSpend = mergedRows.reduce((sum, r) => sum + r.costUsd, 0);
7377
+ const totalTokens = mergedRows.reduce((sum, r) => sum + r.totalTokens, 0);
7378
+ const tasksTracked = mergedRows.length;
7379
+ const avgCostPerTask = tasksTracked > 0 ? totalSpend / tasksTracked : 0;
7380
+ const maxPhaseCost = phaseData.length > 0 ? Math.max(...phaseData.map(p => p.cost)) : 0;
7381
+
7382
+ const sortedRows = [...mergedRows].sort((a, b) => {
7383
+ let cmp = 0;
7384
+ switch (sortCol) {
7385
+ case 'id': cmp = Number(a.id) - Number(b.id); break;
7386
+ case 'name': cmp = a.name.localeCompare(b.name); break;
7387
+ case 'status': cmp = a.status.localeCompare(b.status); break;
7388
+ case 'tokens': cmp = a.totalTokens - b.totalTokens; break;
7389
+ case 'cost': cmp = a.costUsd - b.costUsd; break;
7390
+ case 'records': cmp = a.records - b.records; break;
7391
+ }
7392
+ return sortDir === 'asc' ? cmp : -cmp;
7393
+ });
7394
+
7395
+ const sortIndicator = (col) => sortCol === col ? (sortDir === 'asc' ? ' \u25B2' : ' \u25BC') : '';
7396
+
7397
+ return html`
7398
+ <div class="page-content">
7399
+ <div class="page-header">
7400
+ <h2>Factory Costs</h2>
7401
+ <p>Token usage and cost analytics across all tasks</p>
7402
+ </div>
7403
+
7404
+ <div class="stat-grid mb-24">
7405
+ <div class="stat-card">
7406
+ <div class="stat-value">$${totalSpend.toFixed(2)}</div>
7407
+ <div class="stat-label">Total Spend</div>
7408
+ <div class="stat-dot"></div>
7409
+ </div>
7410
+ <div class="stat-card">
7411
+ <div class="stat-value">${formatTokens(totalTokens)}</div>
7412
+ <div class="stat-label">Total Tokens</div>
7413
+ <div class="stat-dot"></div>
7414
+ </div>
7415
+ <div class="stat-card">
7416
+ <div class="stat-value">${tasksTracked}</div>
7417
+ <div class="stat-label">Tasks Tracked</div>
7418
+ <div class="stat-dot"></div>
7419
+ </div>
7420
+ <div class="stat-card">
7421
+ <div class="stat-value">$${avgCostPerTask.toFixed(2)}</div>
7422
+ <div class="stat-label">Avg Cost / Task</div>
7423
+ <div class="stat-dot"></div>
7424
+ </div>
7425
+ </div>
7426
+
7427
+ <div class="card">
7428
+ <div class="card-header">
7429
+ <h3>Cost by Task</h3>
7430
+ <span class="badge badge-accent">${sortedRows.length} tasks</span>
7431
+ </div>
7432
+ <div class="costs-table-wrapper">
7433
+ <table class="costs-table">
7434
+ <thead>
7435
+ <tr>
7436
+ <th class="costs-th" onClick=${() => handleSort('id')}>ID${sortIndicator('id')}</th>
7437
+ <th class="costs-th" onClick=${() => handleSort('name')}>Task${sortIndicator('name')}</th>
7438
+ <th class="costs-th" onClick=${() => handleSort('status')}>Status${sortIndicator('status')}</th>
7439
+ <th class="costs-th" onClick=${() => handleSort('tokens')}>Tokens${sortIndicator('tokens')}</th>
7440
+ <th class="costs-th" onClick=${() => handleSort('cost')}>Cost (USD)${sortIndicator('cost')}</th>
7441
+ <th class="costs-th" onClick=${() => handleSort('records')}>Records${sortIndicator('records')}</th>
7442
+ </tr>
7443
+ </thead>
7444
+ <tbody>
7445
+ ${sortedRows.map(row => html`
7446
+ <tr key=${row.id} class="costs-tr" onClick=${() => navigate('#/board?item=' + row.id)}>
7447
+ <td class="costs-td costs-td-mono">#${row.id}</td>
7448
+ <td class="costs-td costs-td-name">${row.name}</td>
7449
+ <td class="costs-td"><span class=${'badge ' + statusBadgeClass(row.status)}>${row.status}</span></td>
7450
+ <td class="costs-td costs-td-mono">${formatTokens(row.totalTokens)}</td>
7451
+ <td class="costs-td costs-td-mono">$${row.costUsd.toFixed(2)}</td>
7452
+ <td class="costs-td costs-td-mono">${row.records}</td>
7453
+ </tr>
7454
+ `)}
7455
+ </tbody>
7456
+ </table>
7457
+ </div>
7458
+ </div>
7459
+
7460
+ ${phaseData.length > 0 && html`
7461
+ <div class="card" style="margin-top:24px;">
7462
+ <div class="card-header">
7463
+ <h3>Cost by Phase</h3>
7464
+ </div>
7465
+ <div class="costs-phase-chart">
7466
+ ${phaseData.map(p => html`
7467
+ <div key=${p.phase} class="costs-phase-row">
7468
+ <div class="costs-phase-label">${p.phase}</div>
7469
+ <div class="costs-phase-bar-bg">
7470
+ <div class="costs-phase-bar-fill" style=${'width:' + (maxPhaseCost > 0 ? (p.cost / maxPhaseCost * 100) : 0) + '%'}></div>
7471
+ </div>
7472
+ <div class="costs-phase-amount">$${p.cost.toFixed(2)}</div>
7473
+ </div>
7474
+ `)}
7475
+ </div>
7476
+ </div>
7477
+ `}
7478
+ </div>
7479
+ `;
7480
+ }
7481
+
6898
7482
  // ================================================================
6899
7483
  // Learnings Page
6900
7484
  // ================================================================
@@ -7152,6 +7736,7 @@ function App() {
7152
7736
  case '#/board': page = html`<${BoardPage} selectedProject=${selectedProject} />`; break;
7153
7737
  case '#/strategy': page = html`<${StrategyPage} selectedProject=${selectedProject} />`; break;
7154
7738
  case '#/runs': page = html`<${RunsPage} selectedProject=${selectedProject} />`; break;
7739
+ case '#/costs': page = html`<${CostsPage} selectedProject=${selectedProject} />`; break;
7155
7740
  case '#/learnings': page = html`<${LearningsPage} selectedProject=${selectedProject} />`; break;
7156
7741
  case '#/projects': page = html`<${ProjectsPage} selectedProject=${selectedProject} onProjectChange=${setSelectedProject} />`; break;
7157
7742
  case '#/extensions': page = html`<${ExtensionsPage} selectedProject=${selectedProject} />`; break;
@@ -1 +1 @@
1
- 20260418-082837-7de1d16
1
+ 20260418-125802-c140b02
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beastmode-develeap/beastmode",
3
- "version": "0.1.110",
3
+ "version": "0.1.112",
4
4
  "description": "BeastMode Dark Factory — turn intent into verified software",
5
5
  "type": "module",
6
6
  "bin": {