@monoes/monomindcli 1.10.36 → 1.10.37

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.
@@ -552,6 +552,37 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
552
552
  .lsp-bar.err { background:var(--red); opacity:0.8; }
553
553
  .le-spark { display:flex; align-items:center; gap:8px; }
554
554
 
555
+ /* ── feature 19: cache efficiency ───────────────────────── */
556
+ .eff-row { display:flex; align-items:center; justify-content:space-between; font-size:11px; margin-bottom:2px; gap:6px; }
557
+ .eff-lbl { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-lo); flex:1; min-width:0; font-size:10px; }
558
+ .eff-pct { font-size:10px; font-variant-numeric:tabular-nums; flex-shrink:0; }
559
+ .eff-good { color:oklch(65% 0.15 150); }
560
+ .eff-warn { color:oklch(70% 0.18 80); }
561
+ .eff-bad { color:oklch(65% 0.2 25); }
562
+ .eff-bar-wrap { height:2px; background:var(--border); border-radius:1px; margin-bottom:5px; }
563
+ .eff-bar-fill { height:2px; border-radius:1px; }
564
+
565
+ /* ── feature 21: weekly recap ────────────────────────────── */
566
+ #weekly-card { display:none; border:1px solid var(--border); border-radius:8px; margin:0 18px 10px; padding:10px 12px; background:oklch(14% 0.008 55 / 0.6); }
567
+ #weekly-card.show { display:block; }
568
+ .weekly-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; }
569
+ .weekly-title { font-size:10px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-lo); }
570
+ .weekly-dismiss { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:11px; padding:0; line-height:1; }
571
+ .weekly-dismiss:hover { color:var(--text-lo); }
572
+
573
+ /* ── feature 22: context saturation ─────────────────────── */
574
+ .ctx-sat-wrap { margin-top:4px; }
575
+ .ctx-sat-bar { height:2px; background:var(--border); border-radius:1px; overflow:hidden; }
576
+ .ctx-sat-fill { height:2px; border-radius:1px; }
577
+ .ctx-sat-lbl { font-size:9px; color:var(--text-xs); margin-top:1px; }
578
+
579
+ /* ── feature 24: activity heatmap ───────────────────────── */
580
+ .heatmap-grid { display:flex; flex-direction:column; gap:2px; }
581
+ .heatmap-row { display:flex; align-items:center; gap:1px; }
582
+ .heatmap-lbl { width:14px; font-size:8px; color:var(--text-xs); flex-shrink:0; text-align:right; padding-right:2px; }
583
+ .heatmap-hdr-cell { width:9px; font-size:7px; color:var(--text-xs); text-align:center; flex-shrink:0; }
584
+ .heatmap-cell { width:9px; height:9px; border-radius:1px; flex-shrink:0; cursor:default; }
585
+
555
586
  /* ── budget cap ──────────────────────────────────────────── */
556
587
  #budget-modal { display:none; position:fixed; inset:0; z-index:200; background:oklch(5% 0 0 / 0.6); align-items:center; justify-content:center; }
557
588
  #budget-modal.open { display:flex; }
@@ -697,6 +728,13 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
697
728
  <button class="tf-btn" data-tf="24h" onclick="setFeedTimeFilter('24h')">24h</button>
698
729
  <span class="kb-hint"><kbd>J</kbd><kbd>K</kbd> navigate &nbsp;<kbd>↵</kbd> detail &nbsp;<kbd>/</kbd> find &nbsp;<kbd>G</kbd> live &nbsp;<kbd>A</kbd> ambient &nbsp;<kbd>⌘K</kbd> search</span>
699
730
  </div>
731
+ <div id="weekly-card">
732
+ <div class="weekly-header">
733
+ <span class="weekly-title">This week</span>
734
+ <button class="weekly-dismiss" onclick="dismissWeekly()" title="Dismiss">✕</button>
735
+ </div>
736
+ <div class="digest-row" id="weekly-stats"></div>
737
+ </div>
700
738
  <div id="digest-card">
701
739
  <div class="digest-title">Today's Digest <button class="digest-close" onclick="dismissDigest()" title="Dismiss">✕</button></div>
702
740
  <div class="digest-row" id="digest-stats"></div>
@@ -746,6 +784,14 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
746
784
  <div class="m-group-title">Session Lanes</div>
747
785
  <div class="loading-txt">—</div>
748
786
  </div>
787
+ <div id="m-efficiency">
788
+ <div class="m-group-title">Cache Efficiency</div>
789
+ <div class="loading-txt">—</div>
790
+ </div>
791
+ <div id="m-heatmap">
792
+ <div class="m-group-title">Activity Heatmap</div>
793
+ <div class="loading-txt">—</div>
794
+ </div>
749
795
  </div>
750
796
  </div>
751
797
 
@@ -768,6 +814,8 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
768
814
  <div class="pg-title" style="margin-bottom:0">Sessions</div>
769
815
  <button id="sess-star-filter" onclick="toggleSessStarFilter()" title="Show only bookmarked sessions">☆ Starred</button>
770
816
  <button class="lb-toggle" id="btn-leaderboard" onclick="toggleLeaderboard()" title="Cost leaderboard">⬆ Leaderboard</button>
817
+ <button class="lb-toggle" id="btn-model-mix" onclick="toggleModelMix()" title="Model usage breakdown">⬡ Models</button>
818
+ <button class="lb-toggle" id="btn-tool-errors" onclick="toggleToolErrors()" title="Tool error rate">⚠ Errors</button>
771
819
  <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
772
820
  </div>
773
821
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
@@ -781,6 +829,12 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
781
829
  <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
782
830
  </tr></thead><tbody id="lb-body"></tbody></table>
783
831
  </div>
832
+ <div id="model-mix-panel" style="display:none;margin-bottom:16px">
833
+ <div id="model-mix-body"></div>
834
+ </div>
835
+ <div id="tool-errors-panel" style="display:none;margin-bottom:16px">
836
+ <div id="tool-errors-body"></div>
837
+ </div>
784
838
  <div id="sess-content" class="sess-list"><div class="loading-txt">Loading…</div></div>
785
839
  </div>
786
840
  </div>
@@ -1521,6 +1575,11 @@ async function renderSessions() {
1521
1575
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1522
1576
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1523
1577
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1578
+ const satPct = Math.min(100, Math.round((s.totalMessages || 0) / 200 * 100));
1579
+ const satColor = satPct > 80 ? 'oklch(65% 0.2 25)' : satPct > 50 ? 'oklch(70% 0.18 80)' : 'var(--accent)';
1580
+ const satBar = satPct > 0 ? `<div class="ctx-sat-wrap" title="Context saturation ~${satPct}% (${s.totalMessages||0} turns / 200 est. max)">
1581
+ <div class="ctx-sat-bar"><div class="ctx-sat-fill" style="width:${satPct}%;background:${satColor}"></div></div>
1582
+ </div>` : '';
1524
1583
  const summaries = (s.summaries || []).slice(0, 2).map(sm => { const t = typeof sm === 'string' ? sm : (sm.summary || sm.text || String(sm)); return `<span class="sr-tag">${esc(t.slice(0, 40))}</span>`; }).join('');
1525
1584
  const autoTags = (allTags.sessionTags.get(s.id) || []).map(t => `<span class="sr-autotag">${esc(t)}</span>`).join('');
1526
1585
  const isStarred = bookmarks.has(s.id);
@@ -1536,6 +1595,7 @@ async function renderSessions() {
1536
1595
  </div>
1537
1596
  <div class="sr-meta">${esc(meta)}</div>
1538
1597
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1598
+ ${satBar}
1539
1599
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1540
1600
  <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1541
1601
  <div class="sess-notes-area" id="snote-${esc(s.id)}">
@@ -1550,6 +1610,9 @@ async function renderSessions() {
1550
1610
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1551
1611
  }
1552
1612
  buildDigest();
1613
+ buildWeeklyRecap();
1614
+ buildEfficiencyPanel();
1615
+ buildActivityHeatmap();
1553
1616
  if (leaderboardOpen) renderLeaderboard();
1554
1617
  } catch (err) {
1555
1618
  el.innerHTML = '<div class="empty">Could not load sessions: ' + esc(err.message) + '</div>';
@@ -2335,6 +2398,185 @@ function toggleLoop(row) {
2335
2398
  row.classList.toggle('open');
2336
2399
  }
2337
2400
 
2401
+ // ── feature 19: cache efficiency panel ────────────────────
2402
+ function buildEfficiencyPanel() {
2403
+ const el = document.getElementById('m-efficiency');
2404
+ if (!el) return;
2405
+ const sessions = allSessions.filter(s => s.totalInputTokens > 0);
2406
+ if (!sessions.length) {
2407
+ el.innerHTML = `<div class="m-group-title">Cache Efficiency</div><div class="loading-txt" style="padding:4px 0">No token data yet</div>`;
2408
+ return;
2409
+ }
2410
+ const totalCR = sessions.reduce((a,s) => a + (s.cacheReadTokens||0), 0);
2411
+ const totalIn = sessions.reduce((a,s) => a + (s.totalInputTokens||0), 0);
2412
+ const avgPct = totalIn > 0 ? Math.round(totalCR/totalIn*100) : 0;
2413
+ const avgCls = avgPct >= 60 ? 'eff-good' : avgPct >= 20 ? 'eff-warn' : 'eff-bad';
2414
+ const rows = sessions.slice(0, 5).map(s => {
2415
+ const pct = s.totalInputTokens > 0 ? Math.round((s.cacheReadTokens||0)/s.totalInputTokens*100) : 0;
2416
+ const cls = pct >= 60 ? 'eff-good' : pct >= 20 ? 'eff-warn' : 'eff-bad';
2417
+ const fillColor = pct >= 60 ? 'oklch(65% 0.15 150)' : pct >= 20 ? 'oklch(70% 0.18 80)' : 'oklch(65% 0.2 25)';
2418
+ const lbl = (s.lastPrompt || s.id).slice(0, 22);
2419
+ return `<div class="eff-row">
2420
+ <span class="eff-lbl" title="${esc(s.lastPrompt||s.id)}">${esc(lbl)}</span>
2421
+ <span class="eff-pct ${cls}">${pct}%</span>
2422
+ </div>
2423
+ <div class="eff-bar-wrap"><div class="eff-bar-fill" style="width:${pct}%;background:${fillColor}"></div></div>`;
2424
+ }).join('');
2425
+ el.innerHTML = `<div class="m-group-title">Cache Efficiency <span class="${avgCls}" style="font-size:10px;font-weight:400">${avgPct}% avg</span></div>${rows}`;
2426
+ }
2427
+
2428
+ // ── feature 20: model mix ──────────────────────────────────
2429
+ let modelMixOpen = false;
2430
+ function toggleModelMix() {
2431
+ modelMixOpen = !modelMixOpen;
2432
+ const p = document.getElementById('model-mix-panel');
2433
+ p.style.display = modelMixOpen ? 'block' : 'none';
2434
+ document.getElementById('btn-model-mix').classList.toggle('active', modelMixOpen);
2435
+ if (modelMixOpen) renderModelMix();
2436
+ }
2437
+ function renderModelMix() {
2438
+ const body = document.getElementById('model-mix-body');
2439
+ const breakdown = {};
2440
+ for (const s of allSessions) {
2441
+ for (const [model, d] of Object.entries(s.modelBreakdown || {})) {
2442
+ if (!breakdown[model]) breakdown[model] = { calls: 0, cost: 0 };
2443
+ breakdown[model].calls += d.calls || 0;
2444
+ breakdown[model].cost += d.cost || 0;
2445
+ }
2446
+ }
2447
+ const entries = Object.entries(breakdown).sort((a,b) => b[1].cost - a[1].cost);
2448
+ if (!entries.length) {
2449
+ body.innerHTML = '<div style="color:var(--text-lo);font-size:11px;padding:8px 0">No model data</div>';
2450
+ return;
2451
+ }
2452
+ const totalCost = entries.reduce((a,[,d]) => a + d.cost, 0);
2453
+ body.innerHTML = `<table class="lb-table"><thead><tr>
2454
+ <th>Model</th><th class="lb-cost">Cost</th><th class="lb-dur">%</th><th class="lb-dur">Calls</th>
2455
+ </tr></thead><tbody>
2456
+ ${entries.map(([model, d]) => {
2457
+ const short = model.replace(/^claude-/,'').replace(/-\d{8}$/,'');
2458
+ const pct = totalCost > 0 ? Math.round(d.cost/totalCost*100) : 0;
2459
+ return `<tr>
2460
+ <td style="font-size:11px">${esc(short)}</td>
2461
+ <td class="lb-cost">$${d.cost.toFixed(2)}</td>
2462
+ <td class="lb-dur">${pct}%</td>
2463
+ <td class="lb-dur">${d.calls}</td>
2464
+ </tr>`;
2465
+ }).join('')}
2466
+ </tbody></table>`;
2467
+ }
2468
+
2469
+ // ── feature 21: weekly recap ───────────────────────────────
2470
+ const WEEKLY_DISMISSED_KEY = 'mm-weekly-dismissed';
2471
+ function getWeekKey() {
2472
+ const d = new Date();
2473
+ const sun = new Date(d); sun.setDate(d.getDate() - d.getDay()); sun.setHours(0,0,0,0);
2474
+ return sun.toISOString().slice(0,10);
2475
+ }
2476
+ function buildWeeklyRecap() {
2477
+ if (localStorage.getItem(WEEKLY_DISMISSED_KEY) === getWeekKey()) return;
2478
+ if (!allSessions.length) return;
2479
+ const weekStart = new Date(); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); weekStart.setHours(0,0,0,0);
2480
+ const weekSess = allSessions.filter(s => {
2481
+ const t = s.lastTs || s.mtime;
2482
+ return t && new Date(typeof t === 'number' ? t : t).getTime() >= weekStart.getTime();
2483
+ });
2484
+ if (!weekSess.length) return;
2485
+ const totalCost = weekSess.reduce((a,s) => a + (s.totalCost||0), 0);
2486
+ const totalTools = weekSess.reduce((a,s) => a + (s.toolCalls||0), 0);
2487
+ const days = new Set(weekSess.map(s => {
2488
+ const t = s.lastTs || s.mtime;
2489
+ return new Date(typeof t === 'number' ? t : t).toDateString();
2490
+ })).size;
2491
+ const longestMs = Math.max(...weekSess.map(s => s.totalDurationMs||0));
2492
+ const stats = [
2493
+ `${weekSess.length} session${weekSess.length!==1?'s':''}`,
2494
+ `${days} day${days!==1?'s':''}`,
2495
+ totalCost > 0 ? `$${totalCost.toFixed(2)} spent` : null,
2496
+ totalTools > 0 ? `${totalTools.toLocaleString()} tool calls` : null,
2497
+ longestMs > 0 ? `${fmtDur(longestMs)} longest` : null,
2498
+ ].filter(Boolean);
2499
+ document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2500
+ document.getElementById('weekly-card').classList.add('show');
2501
+ }
2502
+ function dismissWeekly() {
2503
+ localStorage.setItem(WEEKLY_DISMISSED_KEY, getWeekKey());
2504
+ document.getElementById('weekly-card').classList.remove('show');
2505
+ }
2506
+
2507
+ // ── feature 23: tool error rate ────────────────────────────
2508
+ let toolErrorsOpen = false;
2509
+ function toggleToolErrors() {
2510
+ toolErrorsOpen = !toolErrorsOpen;
2511
+ const p = document.getElementById('tool-errors-panel');
2512
+ p.style.display = toolErrorsOpen ? 'block' : 'none';
2513
+ document.getElementById('btn-tool-errors').classList.toggle('active', toolErrorsOpen);
2514
+ if (toolErrorsOpen) loadToolErrors();
2515
+ }
2516
+ async function loadToolErrors() {
2517
+ const body = document.getElementById('tool-errors-body');
2518
+ body.innerHTML = '<div class="loading-txt">Loading…</div>';
2519
+ try {
2520
+ const data = await apiFetch('/api/tool-errors?dir=' + enc(DIR));
2521
+ const errors = data.errors || [];
2522
+ if (!errors.length) {
2523
+ body.innerHTML = '<div style="color:var(--text-lo);font-size:11px;padding:8px 0">No tool errors found in recent sessions</div>';
2524
+ return;
2525
+ }
2526
+ body.innerHTML = `<table class="lb-table"><thead><tr>
2527
+ <th>Tool</th><th class="lb-cost">Errors</th><th class="lb-dur">Rate</th>
2528
+ </tr></thead><tbody>
2529
+ ${errors.map(e => {
2530
+ const rate = e.total > 0 ? Math.round(e.count/e.total*100)+'%' : '—';
2531
+ return `<tr>
2532
+ <td style="font-size:11px">${esc(e.tool)}</td>
2533
+ <td class="lb-cost" style="color:oklch(65% 0.2 25)">${e.count}</td>
2534
+ <td class="lb-dur">${rate}</td>
2535
+ </tr>`;
2536
+ }).join('')}
2537
+ </tbody></table>`;
2538
+ } catch (err) {
2539
+ body.innerHTML = `<div style="color:var(--text-lo);font-size:11px">Error: ${esc(err.message)}</div>`;
2540
+ }
2541
+ }
2542
+
2543
+ // ── feature 24: activity heatmap ──────────────────────────
2544
+ function buildActivityHeatmap() {
2545
+ const el = document.getElementById('m-heatmap');
2546
+ if (!el) return;
2547
+ if (!allSessions.length) {
2548
+ el.innerHTML = `<div class="m-group-title">Activity Heatmap</div><div class="loading-txt" style="padding:4px 0">No sessions</div>`;
2549
+ return;
2550
+ }
2551
+ const grid = Array.from({length:7}, () => new Array(24).fill(0));
2552
+ for (const s of allSessions) {
2553
+ const t = s.firstTs || s.mtime;
2554
+ if (!t) continue;
2555
+ const d = new Date(typeof t === 'number' ? t : t);
2556
+ grid[d.getDay()][d.getHours()]++;
2557
+ }
2558
+ const maxVal = Math.max(1, ...grid.flat());
2559
+ const DAYS = ['Su','Mo','Tu','We','Th','Fr','Sa'];
2560
+ let html = '<div class="heatmap-grid">';
2561
+ html += '<div class="heatmap-row"><div class="heatmap-lbl"></div>';
2562
+ for (let h = 0; h < 24; h++) {
2563
+ html += `<div class="heatmap-hdr-cell">${h % 6 === 0 ? h : ''}</div>`;
2564
+ }
2565
+ html += '</div>';
2566
+ for (let d = 0; d < 7; d++) {
2567
+ html += `<div class="heatmap-row"><div class="heatmap-lbl">${DAYS[d]}</div>`;
2568
+ for (let h = 0; h < 24; h++) {
2569
+ const v = grid[d][h];
2570
+ const alpha = v > 0 ? Math.max(0.18, v/maxVal).toFixed(2) : 0;
2571
+ const bg = v > 0 ? `oklch(65% 0.18 200 / ${alpha})` : 'transparent';
2572
+ html += `<div class="heatmap-cell" style="background:${bg};border:1px solid ${v>0?'transparent':'var(--border)'}" title="${DAYS[d]} ${h}:00 — ${v} session${v!==1?'s':''}"></div>`;
2573
+ }
2574
+ html += '</div>';
2575
+ }
2576
+ html += '</div>';
2577
+ el.innerHTML = `<div class="m-group-title">Activity Heatmap <span style="font-size:10px;color:var(--text-xs);font-weight:400">by day/hr</span></div>${html}`;
2578
+ }
2579
+
2338
2580
  // ── memory ─────────────────────────────────────────────────
2339
2581
  async function renderMemory() {
2340
2582
  activeMemNs = 'All'; // reset on every render so stale filter doesn't persist
@@ -7,7 +7,40 @@ import { createRequire } from 'module';
7
7
  import { collectAll, getWatchPaths, collectProject, collectSessions, collectSwarm, collectSwarmHistory, appendSwarmHistory, collectSwarmEvents, getSwarmDataSize, cleanSwarmData, collectAgents, collectTokens, collectHooks, collectKnowledge, collectMetrics, collectMemory, collectMemoryFiles, collectSystem } from './collector.mjs';
8
8
 
9
9
  const JSONL_SIZE_CAP = 10 * 1024 * 1024; // 10 MB — skip files larger than this in /api/graph
10
- const buildDocsState = new Map(); // key: resolved dir → { status, sections, files, error, startedAt, completedAt }
10
+ const buildDocsState = new Map();
11
+
12
+ // Pricing per token (mirrors token-tracker.cjs FALLBACK_PRICING)
13
+ const _SJ_PRICING = {
14
+ 'claude-opus-4-6': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
15
+ 'claude-opus-4-5': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
16
+ 'claude-opus-4': { in: 15e-6, out: 75e-6, cw: 18.75e-6, cr: 1.5e-6 },
17
+ 'claude-sonnet-4-6': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
18
+ 'claude-sonnet-4-5': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
19
+ 'claude-sonnet-4': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
20
+ 'claude-3-7-sonnet': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
21
+ 'claude-3-5-sonnet': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
22
+ 'claude-haiku-4-5': { in: 1e-6, out: 5e-6, cw: 1.25e-6, cr: 0.1e-6 },
23
+ 'claude-3-5-haiku': { in: 0.8e-6, out: 4e-6, cw: 1e-6, cr: 0.08e-6 },
24
+ 'gpt-4o': { in: 2.5e-6, out: 10e-6, cw: 2.5e-6, cr: 1.25e-6 },
25
+ 'gpt-4o-mini': { in: 0.15e-6, out: 0.6e-6, cw: 0.15e-6, cr: 0.075e-6 },
26
+ 'gemini-2.5-pro': { in: 1.25e-6, out: 10e-6, cw: 1.25e-6, cr: 0.315e-6 },
27
+ };
28
+ function _sjGetPricing(model) {
29
+ const canonical = (model || '').replace(/@.*$/, '').replace(/-\d{8}$/, '');
30
+ if (_SJ_PRICING[canonical]) return _SJ_PRICING[canonical];
31
+ for (const k of Object.keys(_SJ_PRICING)) { if (canonical.startsWith(k) || canonical.includes(k)) return _SJ_PRICING[k]; }
32
+ return null;
33
+ }
34
+ function _sjCalcCost(model, usage) {
35
+ const p = _sjGetPricing(model);
36
+ if (!p || !usage) return 0;
37
+ const webSearch = ((usage.server_tool_use || {}).web_search_requests || 0) * 0.01;
38
+ return (usage.input_tokens || 0) * p.in
39
+ + (usage.output_tokens || 0) * p.out
40
+ + (usage.cache_creation_input_tokens || 0) * p.cw
41
+ + (usage.cache_read_input_tokens || 0) * p.cr
42
+ + webSearch;
43
+ } // key: resolved dir → { status, sections, files, error, startedAt, completedAt }
11
44
 
12
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
46
  const MASTERMIND_DIAGRAM_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>MASTERMIND — Live Dashboard</title>\n<style>\n* { box-sizing: border-box; margin: 0; padding: 0; }\nhtml, body {\n width: 100%; height: 100%; overflow: hidden;\n background: #07071a;\n font-family: 'Azeret Mono', 'Space Mono', 'Courier New', monospace;\n color: #e0e0ff;\n user-select: none;\n}\n\n/* ── Main layout ── */\n#app { display: flex; height: 100vh; }\n#sidebar {\n width: 220px; flex-shrink: 0;\n background: oklch(9% 0.012 186);\n border-right: 1px solid oklch(62% 0.2 186 / 0.18);\n display: flex; flex-direction: column;\n overflow: hidden; z-index: 10;\n}\n#stage-wrap { flex: 1; position: relative; overflow: hidden; }\n#detail-panel {\n width: 0; flex-shrink: 0; overflow: hidden;\n background: oklch(9% 0.012 186);\n border-left: 1px solid oklch(62% 0.2 186 / 0.18);\n transition: width 0.3s ease;\n display: flex; flex-direction: column;\n z-index: 10;\n}\n#detail-panel.open { width: 280px; }\n#stage { position: absolute; inset: 0; width: 100%; height: 100%; }\n\n/* ── Sidebar ── */\n#sb-header {\n padding: 14px 14px 10px;\n border-bottom: 1px solid oklch(62% 0.2 186 / 0.18);\n flex-shrink: 0;\n}\n#sb-title {\n font-size: 8px; letter-spacing: 4px; color: oklch(52% 0.1 186); margin-bottom: 4px;\n}\n.live-row { display: flex; align-items: center; gap: 6px; }\n.l-dot {\n width: 6px; height: 6px; border-radius: 50%;\n background: #252560; flex-shrink: 0;\n transition: background 0.5s;\n}\n.l-dot.on { background: #28c068; }\n@media (prefers-reduced-motion: no-preference) { .l-dot.on { animation: ldp 2s ease-in-out infinite; } }\n@keyframes ldp { 0%,100%{opacity:1} 50%{opacity:0.4} }\n#l-status { font-size: 9px; letter-spacing: 2px; color: oklch(44% 0.08 186); }\n#l-agents { font-size: 8px; color: oklch(40% 0.07 186); margin-left: auto; }\n#sb-sessions {\n flex: 1; overflow-y: auto; padding: 8px 0;\n scrollbar-width: thin; scrollbar-color: oklch(62% 0.2 186 / 0.3) transparent;\n}\n#sb-sessions::-webkit-scrollbar { width: 4px; }\n#sb-sessions::-webkit-scrollbar-thumb { background: oklch(62% 0.2 186 / 0.3); border-radius: 2px; }\n.sess-item {\n padding: 8px 14px; cursor: pointer;\n border-left: 2px solid transparent;\n transition: background 0.15s, border-color 0.15s;\n}\n.sess-item:hover { background: oklch(62% 0.2 186 / 0.09); }\n.sess-item.active { border-left-color: transparent; background: oklch(62% 0.2 186 / 0.14); box-shadow: inset 0 0 0 1px oklch(62% 0.2 186 / 0.32); }\n.sess-item.running { border-left-color: #28c068; }\n.sess-ts { font-size: 10px; color: oklch(42% 0.05 186); margin-bottom: 3px; }\n.sess-prompt {\n font-size: 12px; color: oklch(70% 0.05 186); line-height: 1.4;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 188px;\n}\n.sess-badges { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }\n.sess-project { font-size: 7px; color: oklch(40% 0.1 186); letter-spacing: 1px; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.sess-badge {\n font-size: 8px; padding: 2px 6px; border-radius: 3px;\n border: 1px solid oklch(62% 0.2 186 / 0.25); color: oklch(62% 0.09 186);\n background: oklch(62% 0.2 186 / 0.08);\n}\n.sess-badge.running-badge { border-color: rgba(40,192,104,0.4); color: #28c068; background: rgba(40,192,104,0.08); }\n#git-user-row {\n display: flex; align-items: center; gap: 5px;\n margin-top: 7px; padding-top: 6px;\n border-top: 1px solid oklch(62% 0.2 186 / 0.12);\n}\n#git-user-icon { font-size: 9px; color: #3a3a70; }\n#git-user-name {\n font-size: 9px; letter-spacing: 0.5px; color: #4a4a90;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n#git-cwd-row {\n display: flex; align-items: center; gap: 5px; margin-top: 4px;\n}\n#git-cwd-icon { font-size: 9px; color: #2a2a58; }\n#git-cwd-name {\n font-size: 9px; letter-spacing: 0.3px; color: #38386a;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n direction: rtl; text-align: left;\n}\n.sess-trace-link {\n font-size: 7px; color: #3a3a70; text-decoration: none; letter-spacing: 0.5px;\n padding: 1px 5px; border: 1px solid oklch(62% 0.2 186 / 0.2); border-radius: 3px;\n margin-left: auto; flex-shrink: 0;\n}\n.sess-trace-link:hover { color: oklch(66% 0.11 186); border-color: oklch(62% 0.2 186 / 0.5); }\n.dp-export-btn {\n font-size: 9px; font-family: inherit; color: oklch(58% 0.09 186); text-decoration: none;\n padding: 4px 8px; border: 1px solid oklch(62% 0.2 186 / 0.25); border-radius: 4px;\n background: oklch(62% 0.2 186 / 0.07); cursor: pointer; letter-spacing: 0.3px;\n}\n.dp-export-btn:hover { color: oklch(72% 0.12 186); border-color: oklch(62% 0.2 186 / 0.5); background: oklch(62% 0.2 186 / 0.15); }\n#sb-no-sessions {\n padding: 20px 14px; font-size: 9px; color: oklch(42% 0.06 186); line-height: 1.7;\n text-align: center;\n}\n#sb-movie-btn {\n margin: 10px 14px;\n background: oklch(62% 0.2 186 / 0.12);\n border: 1px solid oklch(62% 0.2 186 / 0.35);\n color: oklch(56% 0.16 186); font-size: 9px; letter-spacing: 2px;\n border-radius: 6px; padding: 7px; cursor: pointer; width: calc(100% - 28px);\n transition: background 0.15s, color 0.15s;\n font-family: 'Azeret Mono', 'Space Mono', 'Courier New', monospace;\n}\n#sb-movie-btn:hover { background: oklch(62% 0.2 186 / 0.25); color: #d0b0ff; }\n#sb-movie-btn.active { background: oklch(62% 0.2 186 / 0.25); color: #d0b0ff; border-color: oklch(62% 0.2 186 / 0.6); }\n\n/* ── SVG title overlay ── */\n#title-wrap {\n position: absolute; top: 16px; left: 50%; transform: translateX(-50%);\n text-align: center; pointer-events: none; z-index: 5;\n}\n#title-h1 {\n font-size: 22px; font-weight: 900; letter-spacing: 0.38em;\n color: oklch(84% 0.14 186);\n}\n#title-sub { font-size: 9px; color: oklch(38% 0.06 186); letter-spacing: 3px; margin-top: 6px; }\n\n/* ── Prompt box ── */\n#prompt-box {\n position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);\n min-width: 340px; max-width: 500px;\n background: rgba(6,4,22,0.96);\n border: 1px solid rgba(130,80,255,0.5);\n border-radius: 12px; padding: 10px 18px;\n z-index: 50; opacity: 0;\n box-shadow: 0 4px 28px rgba(100,50,255,0.16);\n backdrop-filter: blur(18px);\n}\n#p-tag { font-size: 8px; letter-spacing: 3px; color: #48489a; margin-bottom: 4px; }\n#p-line { font-size: 12.5px; color: #90c8ff; display: flex; align-items: center; gap: 2px; min-height: 19px; }\n#p-cursor {\n display: inline-block; width: 2px; height: 14px;\n background: #90c8ff; flex-shrink: 0;\n animation: blink 0.8s step-end infinite;\n}\n@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }\n\n/* ── Activity log ── */\n#activity-log {\n position: absolute; left: 10px; bottom: 76px;\n width: 240px;\n background: rgba(5,3,18,0.93);\n border: 1px solid rgba(70,45,165,0.35);\n border-radius: 10px; padding: 9px 12px;\n z-index: 50; opacity: 0;\n}\n#log-title { font-size: 7.5px; letter-spacing: 3px; color: #282870; margin-bottom: 6px;\n padding-bottom: 5px; border-bottom: 1px solid rgba(70,45,165,0.18); }\n#log-entries { font-size: 9px; line-height: 1.95; max-height: 160px; overflow: hidden; }\n.log-row { display: flex; gap: 5px; opacity: 0; }\n.log-tag { font-weight: bold; min-width: 58px; flex-shrink: 0; }\n.log-msg { color: #525298; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }\n\n/* ── Mode banner ── */\n#mode-banner {\n position: absolute; top: 14px; right: 10px;\n font-size: 8px; letter-spacing: 3px; color: #303070;\n z-index: 5; pointer-events: none;\n}\n#mode-banner.live-mode { color: #28c068; }\n\n/* ── Control bar ── */\n#ctrl {\n position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);\n display: flex; align-items: center; gap: 7px;\n background: rgba(8,6,26,0.95);\n border: 1px solid rgba(100,60,220,0.35);\n border-radius: 26px; padding: 6px 16px;\n z-index: 100; backdrop-filter: blur(18px);\n opacity: 0;\n}\n.c-btn {\n background: none; border: 1px solid rgba(100,60,220,0.4);\n color: #7858d0; width: 26px; height: 26px; border-radius: 50%;\n cursor: pointer; font-size: 10px;\n display: flex; align-items: center; justify-content: center;\n transition: background 0.12s, color 0.12s; flex-shrink: 0; line-height: 1;\n}\n.c-btn:hover { background: rgba(100,60,220,0.2); color: #d0b0ff; }\n.c-btn.disabled { opacity: 0.3; pointer-events: none; }\n#scrubber {\n width: 180px; height: 3px; cursor: pointer;\n -webkit-appearance: none; appearance: none;\n background: rgba(100,60,220,0.2); border-radius: 2px; outline: none;\n}\n#scrubber::-webkit-slider-thumb {\n -webkit-appearance: none; width: 11px; height: 11px;\n border-radius: 50%; background: #7858d0; cursor: pointer; border: none;\n}\n#t-disp { font-size: 9px; color: #484888; min-width: 36px; text-align: right; font-variant-numeric: tabular-nums; }\n#spd {\n background: rgba(8,6,26,0.85); border: 1px solid rgba(100,60,220,0.3);\n color: oklch(55% 0.12 186); font-size: 9px; font-family: 'Azeret Mono', 'Space Mono', monospace;\n border-radius: 4px; padding: 2px 4px; cursor: pointer; outline: none;\n}\n#spd option { background: #0d0a20; }\n\n/* ── Detail panel ── */\n#dp-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid oklch(62% 0.2 186 / 0.18); flex-shrink: 0;\n}\n#dp-close {\n float: right; background: none; border: none; color: #404070;\n cursor: pointer; font-size: 13px; padding: 0; line-height: 1;\n}\n#dp-close:hover { color: #a090e0; }\n#dp-title { font-size: 9px; letter-spacing: 3px; color: #5050a0; margin-top: 2px; }\n#dp-emoji { font-size: 22px; display: block; margin-bottom: 4px; }\n#dp-body { flex: 1; overflow-y: auto; padding: 12px 16px; scrollbar-width: thin; scrollbar-color: oklch(62% 0.2 186 / 0.3) transparent; }\n#dp-body::-webkit-scrollbar { width: 4px; }\n#dp-body::-webkit-scrollbar-thumb { background: oklch(62% 0.2 186 / 0.3); border-radius: 2px; }\n.dp-section { margin-bottom: 14px; }\n.dp-section-title { font-size: 7.5px; letter-spacing: 3px; color: oklch(38% 0.07 186); margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid oklch(62% 0.2 186 / 0.15); }\n.dp-event { font-size: 9px; line-height: 1.6; color: #5060a0; margin-bottom: 4px; }\n.dp-event .ev-ts { color: #282855; }\n.dp-event .ev-type { color: inherit; font-weight: bold; }\n.dp-artifact { font-size: 9px; color: #6070a0; padding: 3px 6px; background: oklch(62% 0.2 186 / 0.08); border-radius: 3px; margin-bottom: 3px; }\n.dp-agent { display: inline-block; font-size: 8px; padding: 2px 7px; border-radius: 10px; margin: 2px 3px 2px 0; border: 1px solid oklch(62% 0.2 186 / 0.3); color: oklch(55% 0.09 186); }\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n }\n}\n</style>\n</head>\n<body>\n<div id=\"app\">\n <!-- ── Left sidebar: session history ── -->\n <div id=\"sidebar\">\n <div id=\"sb-header\">\n <div id=\"sb-title\">SESSIONS</div>\n <select id=\"proj-filter\" onchange=\"applyProjectFilter(this.value)\" style=\"width:100%;margin-top:6px;background:oklch(12% 0.015 186);color:oklch(52% 0.1 186);border:1px solid oklch(62% 0.2 186 / 0.18);border-radius:3px;font-size:8px;letter-spacing:1px;padding:3px 4px;cursor:pointer\"><option value=\"\">ALL PROJECTS</option></select>\n <div class=\"live-row\">\n <div class=\"l-dot\" id=\"l-dot\"></div>\n <span id=\"l-status\">OFFLINE</span>\n <span id=\"l-agents\">0 agents</span>\n </div>\n <div id=\"git-user-row\">\n <span id=\"git-user-icon\">⬡</span>\n <span id=\"git-user-name\">—</span>\n </div>\n <div id=\"git-cwd-row\">\n <span id=\"git-cwd-icon\">◎</span>\n <span id=\"git-cwd-name\">—</span>\n </div>\n </div>\n <div id=\"sb-sessions\">\n <div id=\"sb-no-sessions\">No sessions yet.<br><br>Describe a goal and<br>Mastermind routes it<br>across specialist agents.<br><br><span style=\"color:oklch(56% 0.16 186);letter-spacing:1px\">/mastermind</span></div>\n </div>\n <button id=\"sb-movie-btn\" onclick=\"toggleMovieMode(currentSessionObj)\">▶ MOVIE MODE</button>\n </div>\n\n <!-- ── Stage ── -->\n <div id=\"stage-wrap\">\n <!-- SVG -->\n <svg id=\"stage\" viewBox=\"0 0 960 720\" preserveAspectRatio=\"xMidYMid meet\">\n <defs>\n <filter id=\"glow\" x=\"-55%\" y=\"-55%\" width=\"210%\" height=\"210%\">\n <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"b\"/>\n <feMerge><feMergeNode in=\"b\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>\n </filter>\n <filter id=\"bloom\" x=\"-100%\" y=\"-100%\" width=\"300%\" height=\"300%\">\n <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"15\" result=\"b\"/>\n <feMerge><feMergeNode in=\"b\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>\n </filter>\n </defs>\n <rect width=\"960\" height=\"720\" fill=\"#07071a\"/>\n <g id=\"stars\"></g>\n <g id=\"net-edges\"></g>\n <g id=\"net-particles\"></g>\n <g id=\"net-nodes\"></g>\n </svg>\n\n <!-- Overlays -->\n <div id=\"title-wrap\">\n <div id=\"title-h1\">MASTERMIND</div>\n <div id=\"title-sub\">AUTONOMOUS EXECUTION · 12 DOMAINS · PERSISTENT ORGS</div>\n </div>\n\n <div id=\"mode-banner\">LIVE</div>\n\n <div id=\"prompt-box\">\n <div id=\"p-tag\">USER PROMPT</div>\n <div id=\"p-line\"><span id=\"p-text\"></span><span id=\"p-cursor\"></span></div>\n </div>\n\n <div id=\"activity-log\">\n <div id=\"log-title\">ACTIVITY LOG</div>\n <div id=\"log-entries\"></div>\n </div>\n\n <div id=\"ctrl\">\n <button class=\"c-btn disabled\" id=\"btn-restart\" title=\"Restart\">↺</button>\n <button class=\"c-btn disabled\" id=\"btn-play\" title=\"Play\">▶</button>\n <button class=\"c-btn disabled\" id=\"btn-pause\" title=\"Pause\">⏸</button>\n <input type=\"range\" id=\"scrubber\" min=\"0\" max=\"100\" value=\"0\" step=\"0.1\" disabled/>\n <span id=\"t-disp\">—</span>\n <select id=\"spd\">\n <option value=\"0.5\">0.5×</option>\n <option value=\"1\" selected>1×</option>\n <option value=\"2\">2×</option>\n <option value=\"3\">3×</option>\n </select>\n </div>\n </div>\n\n <!-- ── Right panel: session/domain detail ── -->\n <div id=\"detail-panel\">\n <div id=\"dp-header\">\n <button id=\"dp-close\" onclick=\"closeDetail()\">✕</button>\n <span id=\"dp-emoji\"></span>\n <div id=\"dp-title\">SELECT A DOMAIN OR SESSION</div>\n </div>\n <div id=\"dp-body\"></div>\n </div>\n</div>\n\n<script src=\"https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js\"></script>\n<script>\n'use strict';\n\n// ── Graph constants ──────────────────────────────────────────────────────────\nconst CX = 480, CY = 360;\nconst DOMAIN_COLORS = {\n build:'#60a5fa', idea:'#fbbf24', marketing:'#f472b6', review:'#34d399',\n research:'#a78bfa', content:'#fb923c', release:'#22d3ee', sales:'#f87171',\n ops:'#4ade80', finance:'#fde68a', orgs:'#c084fc', default:'#00E5C8'\n};\nconst DOMAIN_EMOJIS = {\n build:'⚙️', idea:'💡', marketing:'📣', review:'🔍', research:'🔬',\n content:'✍️', release:'🚀', sales:'💼', ops:'⚡', finance:'💰', orgs:'🏛'\n};\nconst AGENT_EMOJIS = {\n 'coder':'⚙', 'architect':'🏗', 'tester':'🧪', 'reviewer':'🔍',\n 'researcher':'🔬', 'frontend-dev':'🎨', 'backend-dev':'⚡',\n 'coordinator':'🎯', 'planner':'📋', 'general-purpose':'🤖',\n 'frontend':'🎨', 'backend':'⚡', 'ml-developer':'🧠',\n 'security-architect':'🔒', 'sparc-coder':'💻', 'default':'◈'\n};\n\n// ── Node/edge model ───────────────────────────────────────────────────────────\nconst nodes = new Map();\nconst edges = [];\nlet rootId = null;\nlet simActive = false;\n\n// ── SVG helpers ───────────────────────────────────────────────────────────────\nconst NS = 'http://www.w3.org/2000/svg';\nconst mkN = (tag, a) => {\n const el = document.createElementNS(NS, tag);\n if (a) for (const [k,v] of Object.entries(a)) el.setAttribute(k, v);\n return el;\n};\nconst starsG = document.getElementById('stars');\nconst edgesG = document.getElementById('net-edges');\nconst particlesG= document.getElementById('net-particles');\nconst nodesG = document.getElementById('net-nodes');\n\n// ── Star field ────────────────────────────────────────────────────────────────\n(function buildStars() {\n for (let i = 0; i < 170; i++) {\n starsG.appendChild(mkN('circle', {\n cx: (Math.random()*960).toFixed(1),\n cy: (Math.random()*720).toFixed(1),\n r: (Math.random()<0.1 ? Math.random()*1.5+0.8 : Math.random()*0.8+0.15).toFixed(2),\n fill: `rgba(160,150,255,${(Math.random()*0.35+0.08).toFixed(2)})`\n }));\n }\n if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n gsap.to([...starsG.children], {\n opacity: 'random(0.06,0.6)', duration: 'random(2,5)',\n stagger:{ amount:16, from:'random', repeat:-1, yoyo:true, ease:'sine.inOut' }, delay:1\n });\n }\n})();\n\n// ── Hex helper ────────────────────────────────────────────────────────────────\nfunction hexPts(r) {\n return Array.from({length:6},(_,i)=>{\n const a=i*Math.PI/3-Math.PI/6;\n return `${(r*Math.cos(a)).toFixed(1)},${(r*Math.sin(a)).toFixed(1)}`;\n }).join(' ');\n}\n\n// ── Force simulation (Verlet) ─────────────────────────────────────────────────\nconst SPRING_K = 0.030;\nconst REPULSION = 6000;\nconst DAMPING = 0.78;\nconst REST_DIST = { root:0, domain:185, agent:68, org:160 };\n\nfunction forceStep() {\n const arr = [...nodes.values()];\n for (const n of arr) { n.ax=0; n.ay=0; }\n for (let i=0; i<arr.length; i++) {\n for (let j=i+1; j<arr.length; j++) {\n const a=arr[i], b=arr[j];\n const dx=b.x-a.x, dy=b.y-a.y;\n const d2=dx*dx+dy*dy+1, d=Math.sqrt(d2);\n const f=REPULSION/(d2*d);\n if (!a.fixed){a.ax-=dx*f; a.ay-=dy*f;}\n if (!b.fixed){b.ax+=dx*f; b.ay+=dy*f;}\n }\n }\n for (const e of edges) {\n const a=nodes.get(e.fromId), b=nodes.get(e.toId);\n if (!a||!b) continue;\n const dx=b.x-a.x, dy=b.y-a.y;\n const d=Math.sqrt(dx*dx+dy*dy)+0.001;\n const rest=REST_DIST[b.type]??110;\n const f=(d-rest)*SPRING_K;\n if (!a.fixed){a.ax+=dx/d*f; a.ay+=dy/d*f;}\n if (!b.fixed){b.ax-=dx/d*f; b.ay-=dy/d*f;}\n }\n for (const n of arr) {\n if (n.fixed) continue;\n n.vx=(n.vx+n.ax)*DAMPING;\n n.vy=(n.vy+n.ay)*DAMPING;\n n.x=Math.max(60,Math.min(900,n.x+n.vx));\n n.y=Math.max(60,Math.min(660,n.y+n.vy));\n }\n}\n\n// ── Node renderer ─────────────────────────────────────────────────────────────\nfunction buildNodeEl(n) {\n const g=mkN('g',{transform:`translate(${n.x.toFixed(1)},${n.y.toFixed(1)})`});\n g.style.opacity='0'; g.style.cursor='pointer';\n if (n.type==='root') {\n g.appendChild(mkN('circle',{r:'58',fill:'none',stroke:n.color,'stroke-width':'0.5',opacity:'0.15'}));\n g.appendChild(mkN('circle',{r:'38',fill:'#070620',stroke:n.color,'stroke-width':'2.8',filter:'url(#glow)'}));\n g.appendChild(mkN('circle',{r:'30',fill:'none',stroke:n.color,'stroke-width':'0.8',opacity:'0.35'}));\n const hex=mkN('polygon',{points:hexPts(16),fill:'none',stroke:n.color,'stroke-width':'1.8',opacity:'0.75'});\n g.appendChild(hex);\n gsap.to(hex,{rotate:360,transformOrigin:'0 0',duration:24,repeat:-1,ease:'none'});\n const lbl=mkN('text',{x:'0',y:'58','text-anchor':'middle','font-size':'6.5',fill:n.color,'letter-spacing':'2',\n 'font-family':\"'Azeret Mono','Space Mono',monospace\"});\n lbl.textContent='MASTERMIND'; g.appendChild(lbl);\n } else if (n.type==='domain') {\n g.appendChild(mkN('circle',{r:'44',fill:'none',stroke:n.color,'stroke-width':'0.5',opacity:'0.2'}));\n g.appendChild(mkN('circle',{r:'30',fill:'#09071e',stroke:n.color,'stroke-width':'2.5',filter:'url(#glow)'}));\n const emj=mkN('text',{x:'0',y:'9','text-anchor':'middle','font-size':'17'});\n emj.textContent=n.emoji||'◈'; g.appendChild(emj);\n const lbl=mkN('text',{x:'0',y:'45','text-anchor':'middle','font-size':'7',fill:n.color,'letter-spacing':'1.5',\n 'font-family':\"'Azeret Mono','Space Mono',monospace\"});\n lbl.textContent=n.label; g.appendChild(lbl);\n const ring=mkN('circle',{r:'34',fill:'none',stroke:'#fbbf24','stroke-width':'2',opacity:'0',\n transform:'rotate(-90)','stroke-dasharray':'213.6','stroke-dashoffset':'213.6','stroke-linecap':'round'});\n ring.dataset.cring=n.id;\n g.appendChild(ring);\n } else if (n.type==='agent') {\n g.appendChild(mkN('circle',{r:'20',fill:'#08061a',stroke:n.color,'stroke-width':'1.8',filter:'url(#glow)'}));\n const emj=mkN('text',{x:'0',y:'5','text-anchor':'middle','font-size':'12'});\n emj.textContent=n.emoji||'◈'; g.appendChild(emj);\n const sl=n.label.length>11?n.label.slice(0,10)+'…':n.label;\n const lbl=mkN('text',{x:'0',y:'31','text-anchor':'middle','font-size':'6',fill:n.color,'letter-spacing':'0.6',\n 'font-family':\"'Azeret Mono','Space Mono',monospace\"});\n lbl.textContent=sl; g.appendChild(lbl);\n } else if (n.type==='org') {\n g.appendChild(mkN('polygon',{points:'0,-38 32,0 0,38 -32,0',fill:'#09071e',stroke:n.color,'stroke-width':'2.5',filter:'url(#glow)'}));\n const emj=mkN('text',{x:'0',y:'7','text-anchor':'middle','font-size':'16'});\n emj.textContent='🏛'; g.appendChild(emj);\n const lbl=mkN('text',{x:'0',y:'53','text-anchor':'middle','font-size':'6.5',fill:n.color,'letter-spacing':'1.5',\n 'font-family':\"'Azeret Mono','Space Mono',monospace\"});\n lbl.textContent=n.label; g.appendChild(lbl);\n }\n g.appendChild(mkN('circle',{r:n.type==='agent'?'22':'50',fill:'transparent'}));\n nodesG.appendChild(g);\n n.el=g;\n gsap.to(g,{opacity:1,duration:0.4,ease:'power2.out'});\n gsap.from(g,{scale:0.15,transformOrigin:'0 0',duration:0.55,ease:'back.out(1.7)'});\n}\n\n// ── Edge renderer ─────────────────────────────────────────────────────────────\nfunction buildEdgeEl(e) {\n const a=nodes.get(e.fromId), b=nodes.get(e.toId);\n if (!a||!b) return;\n const g=mkN('g'); g.style.opacity='0';\n const isIC=e.type==='intercom';\n const path=mkN('path',{fill:'none',stroke:a.color,opacity:isIC?'0.75':'0.4',\n 'stroke-width':isIC?'1.5':'0.9','stroke-dasharray':isIC?'5 3':'none'});\n g.appendChild(path);\n if (isIC&&e.msg) {\n const txt=mkN('text',{'font-size':'6.5',fill:a.color,\n 'font-family':\"'Azeret Mono','Space Mono',monospace\",'letter-spacing':'0.4',opacity:'0.8'});\n txt.textContent=e.msg.length>24?e.msg.slice(0,23)+'…':e.msg;\n g.appendChild(txt); e.msgEl=txt;\n }\n edgesG.insertBefore(g,edgesG.firstChild);\n e.el=g; e.pathEl=path;\n gsap.to(g,{opacity:1,duration:0.6,delay:0.12});\n updateEdge(e);\n}\n\nfunction updateEdge(e) {\n const a=nodes.get(e.fromId), b=nodes.get(e.toId);\n if (!a||!b||!e.pathEl) return;\n if (e.type==='intercom') {\n const mx=(a.x+b.x)/2, my=(a.y+b.y)/2-65;\n e.pathEl.setAttribute('d',`M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${mx.toFixed(1)},${my.toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}`);\n if (e.msgEl){e.msgEl.setAttribute('x',(mx-22).toFixed(1));e.msgEl.setAttribute('y',(my-9).toFixed(1));}\n } else {\n e.pathEl.setAttribute('d',`M${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)}`);\n }\n}\n\n// ── Particle system ───────────────────────────────────────────────────────────\nconst PPC = 7;\nfunction spawnParticles(e) {\n const col=(nodes.get(e.fromId)||{color:'#00E5C8'}).color;\n e.ptcls=Array.from({length:PPC},(_,i)=>{\n const dot=mkN('circle',{r:'2',fill:col,opacity:'0'});\n particlesG.appendChild(dot);\n return {el:dot, t:i/PPC};\n });\n}\nfunction tickParticles() {\n for (const e of edges) {\n if (!e.ptcls) continue;\n const a=nodes.get(e.fromId), b=nodes.get(e.toId);\n if (!a||!b) continue;\n for (const p of e.ptcls) {\n p.t=(p.t+0.0045)%1;\n const t=p.t;\n let px,py;\n if (e.type==='intercom') {\n const mx=(a.x+b.x)/2, my=(a.y+b.y)/2-65;\n px=(1-t)*(1-t)*a.x+2*(1-t)*t*mx+t*t*b.x;\n py=(1-t)*(1-t)*a.y+2*(1-t)*t*my+t*t*b.y;\n } else {\n px=a.x+(b.x-a.x)*t; py=a.y+(b.y-a.y)*t;\n }\n p.el.setAttribute('cx',px.toFixed(1));\n p.el.setAttribute('cy',py.toFixed(1));\n p.el.setAttribute('opacity',(Math.sin(t*Math.PI)*0.85).toFixed(2));\n }\n }\n}\n\n// ── RAF render loop ───────────────────────────────────────────────────────────\nlet rafLast=0;\nfunction rafLoop(ts) {\n if (ts-rafLast>=16) {\n if (simActive) forceStep();\n for (const n of nodes.values()) {\n if (n.el) n.el.setAttribute('transform',`translate(${n.x.toFixed(1)},${n.y.toFixed(1)})`);\n }\n for (const e of edges) updateEdge(e);\n tickParticles();\n rafLast=ts;\n }\n requestAnimationFrame(rafLoop);\n}\nrequestAnimationFrame(rafLoop);\n\n// ── Graph mutation API ────────────────────────────────────────────────────────\nfunction gAddNode({id,type,domain,agentSlug,label,emoji,color,parentId,cmd}) {\n if (nodes.has(id)) return nodes.get(id);\n const par=parentId?nodes.get(parentId):null;\n const px=par?par.x:CX, py=par?par.y:CY;\n const ang=Math.random()*Math.PI*2;\n const dist={root:0,domain:175,agent:75,org:155}[type]??120;\n const n={\n id,type,domain,agentSlug,\n label:label||id,\n emoji:emoji||(agentSlug?(AGENT_EMOJIS[agentSlug]||AGENT_EMOJIS.default):'◈'),\n color:color||(domain?(DOMAIN_COLORS[domain]||DOMAIN_COLORS.default):DOMAIN_COLORS.default),\n x:type==='root'?CX:px+Math.cos(ang)*dist+(Math.random()-.5)*28,\n y:type==='root'?CY:py+Math.sin(ang)*dist+(Math.random()-.5)*28,\n vx:0,vy:0,ax:0,ay:0,\n fixed:type==='root',\n parentId:parentId||null,\n cmd:cmd||null,\n done:false,\n state:'active',ts:Date.now()\n };\n nodes.set(id,n);\n simActive=nodes.size>1;\n buildNodeEl(n);\n return n;\n}\nfunction gAddEdge({id,fromId,toId,type,msg}) {\n const eid=id||`${fromId}→${toId}`;\n if (edges.find(e=>e.id===eid)) return;\n const e={id:eid,fromId,toId,type:type||'activation',msg};\n edges.push(e);\n buildEdgeEl(e);\n spawnParticles(e);\n}\nfunction gComplete(id) {\n const cn = nodes.get(id); if (cn) cn.done = true;\n const n=nodes.get(id);\n if (!n||!n.el) return;\n n.state='complete';\n const circ=n.el.querySelector('circle[r=\"30\"]')||n.el.querySelector('circle');\n if (circ) gsap.to(circ,{attr:{stroke:'#fbbf24'},duration:0.3,yoyo:true,repeat:2,\n onComplete:()=>gsap.to(n.el,{opacity:0.65,duration:1.5})});\n const ring=n.el.querySelector('[data-cring]');\n if (ring) gsap.to(ring,{opacity:1,'stroke-dashoffset':0,duration:1.6,ease:'power1.inOut'});\n}\nfunction gClear() {\n nodes.clear(); edges.length=0; rootId=null; simActive=false;\n nodesG.innerHTML=''; edgesG.innerHTML=''; particlesG.innerHTML='';\n}\nfunction pulseRoot() {\n const n=nodes.get(rootId);\n if (!n||!n.el) return;\n const c=n.el.querySelector('circle[r=\"38\"]');\n if (c) gsap.to(c,{attr:{'stroke-width':6},duration:0.25,yoyo:true,repeat:1});\n}\n\n// ── Activity log ──────────────────────────────────────────────────────────────\nfunction addLog(tag,msg,color) {\n const wrap=document.getElementById('log-entries');\n const row=document.createElement('div');\n row.className='log-row';\n row.innerHTML=`<span class=\"log-tag\" style=\"color:${color}\">${tag}</span><span class=\"log-msg\">${msg}</span>`;\n wrap.appendChild(row);\n gsap.fromTo(row,{opacity:0},{opacity:1,duration:0.3});\n const rows=wrap.querySelectorAll('.log-row');\n if (rows.length>10) gsap.to(rows[0],{opacity:0,height:0,duration:0.22,onComplete:()=>rows[0].remove()});\n}\n\n// ── Movie mode ────────────────────────────────────────────────────────────────\nlet isMovieMode=false;\nlet movieTl=null;\n\nfunction buildMovieTl(sessionData) {\n gClear();\n document.getElementById('log-entries').innerHTML='';\n document.getElementById('p-text').textContent='';\n const evts=[...(sessionData&&sessionData.events?sessionData.events:[])].sort((a,b)=>(a.ts||0)-(b.ts||0));\n const tl=gsap.timeline({paused:true,defaults:{ease:'power2.out'}});\n if (!evts.length) {\n tl.add(()=>addLog('[DEMO]','Select a session from the sidebar','#00E5C8'),0.2);\n return tl;\n }\n evts.forEach((ev,i)=>{\n const ev2=Object.assign({},ev);\n tl.add(()=>handleGraphEvent(ev2), 0.3+i*0.75);\n });\n tl.duration(0.3+evts.length*0.75+1.5);\n return tl;\n}\n\nfunction toggleMovieMode(sessionData) {\n isMovieMode=!isMovieMode;\n const btn=document.getElementById('sb-movie-btn');\n const banner=document.getElementById('mode-banner');\n const scrubEl=document.getElementById('scrubber');\n const tDisp=document.getElementById('t-disp');\n if (isMovieMode) {\n btn.classList.add('active'); btn.textContent='■ EXIT MOVIE';\n banner.textContent='MOVIE'; banner.classList.remove('live-mode');\n ['btn-restart','btn-play','btn-pause'].forEach(id=>document.getElementById(id).classList.remove('disabled'));\n scrubEl.disabled=false;\n if (movieTl) movieTl.kill();\n movieTl=buildMovieTl(sessionData);\n document.getElementById('btn-play').onclick=()=>movieTl.resume();\n document.getElementById('btn-pause').onclick=()=>movieTl.pause();\n document.getElementById('btn-restart').onclick=()=>{\n gClear(); document.getElementById('log-entries').innerHTML='';\n movieTl=buildMovieTl(sessionData); movieTl.play();\n };\n document.getElementById('spd').onchange=e=>movieTl&&movieTl.timeScale(Number(e.target.value));\n let scrubbing=false;\n scrubEl.addEventListener('mousedown',()=>{scrubbing=true;movieTl&&movieTl.pause();});\n scrubEl.addEventListener('mouseup',()=>{scrubbing=false;});\n scrubEl.addEventListener('input',()=>{if(movieTl)movieTl.progress(Number(scrubEl.value)/100);tDisp.textContent=(movieTl?movieTl.time():0).toFixed(1)+'s';});\n gsap.ticker.add(()=>{\n if (!scrubbing&&movieTl&&movieTl.totalDuration()>0) {\n scrubEl.value=movieTl.progress()*100;\n tDisp.textContent=movieTl.time().toFixed(1)+'s';\n }\n });\n gsap.to('#ctrl',{opacity:1,duration:0.35});\n movieTl.play();\n } else {\n btn.classList.remove('active'); btn.textContent='▶ MOVIE MODE';\n banner.textContent='LIVE'; banner.classList.add('live-mode');\n ['btn-restart','btn-play','btn-pause'].forEach(id=>document.getElementById(id).classList.add('disabled'));\n scrubEl.disabled=true; tDisp.textContent='—';\n if (movieTl){movieTl.kill();movieTl=null;}\n gsap.to('#ctrl',{opacity:0,duration:0.25});\n }\n}\n\n// ── Core event dispatcher ─────────────────────────────────────────────────────\nfunction handleGraphEvent(ev) {\n const {type,session,domain,agent,from,to,msg,cmd,prompt,status} = ev;\n if (type==='session:start') {\n gClear(); rootId=session;\n gAddNode({id:session,type:'root',label:'MASTERMIND',color:DOMAIN_COLORS.default});\n if (prompt) {\n document.getElementById('p-tag').textContent='RUNNING';\n document.getElementById('p-text').textContent=prompt;\n gsap.to('#prompt-box',{opacity:1,duration:0.4});\n }\n gsap.to('#activity-log',{opacity:1,duration:0.4,delay:0.2});\n gsap.to('#ctrl',{opacity:1,duration:0.4,delay:0.4});\n addLog('[SESSION]',(prompt||session).slice(0,32),'#00E5C8');\n refreshSessions();\n } else if (type==='domain:dispatch') {\n if (!domain||!rootId) return;\n const domId=`${session}:${domain}`;\n gAddNode({id:domId,type:'domain',domain,parentId:rootId,cmd:cmd||null,\n label:domain.toUpperCase(),emoji:DOMAIN_EMOJIS[domain]||'◈'});\n gAddEdge({fromId:rootId,toId:domId,type:'activation'});\n pulseRoot();\n addLog(`[${domain.toUpperCase()}]`,cmd||domain,DOMAIN_COLORS[domain]||'#00E5C8');\n } else if (type==='agent:spawn') {\n if (!domain||!rootId) return;\n const domId=`${session}:${domain}`;\n const agId=`${session}:${domain}:${agent||'agent'}:${ev._replayIdx!==undefined?ev._replayIdx:Date.now()}`;\n gAddNode({id:agId,type:'agent',domain,agentSlug:agent,parentId:domId,\n label:agent||'agent',emoji:AGENT_EMOJIS[agent]||AGENT_EMOJIS.default});\n gAddEdge({fromId:domId,toId:agId,type:'spawn'});\n addLog(`[${(agent||'agent').slice(0,9)}]`,ev.task||agent||'',DOMAIN_COLORS[domain]||'#00E5C8');\n } else if (type==='intercom') {\n if (!from||!to||!rootId) return;\n gAddEdge({id:`ic-${from}-${to}-${Date.now()}`,fromId:`${session}:${from}`,\n toId:`${session}:${to}`,type:'intercom',msg});\n addLog('[IC]',`${from}→${to}: ${msg||''}`,'#c084fc');\n } else if (type==='domain:complete') {\n gComplete(`${session}:${domain}`);\n pulseRoot();\n addLog(`[${(domain||'').toUpperCase()}]`,`done · ${status||'✓'}`,'#34d399');\n refreshSessions();\n } else if (type==='session:complete') {\n for (const n of nodes.values()) {\n if (n.el) gsap.to(n.el,{opacity:1,duration:0.3,yoyo:true,repeat:2,ease:'power1.inOut'});\n }\n addLog('[✓]',`complete — ${(ev.domains||[]).length||'all'} domains`,'#34d399');\n setTimeout(()=>gsap.to('#prompt-box',{opacity:0,duration:1}),4000);\n refreshSessions();\n }\n}\n\n// ── Live event handler ────────────────────────────────────────────────────────\nfunction handleLiveEvent(ev) {\n if (isMovieMode) return;\n handleGraphEvent(ev);\n}\n\n\n// ── Session graph replay ───────────────────────────────────────────────\nfunction replaySessionGraph(events) {\n if (!events || !events.length) return;\n gClear();\n let skipRefresh = true;\n const origRefresh = window.refreshSessions;\n window.refreshSessions = () => {}; // suppress during replay\n events.forEach((ev, idx) => handleGraphEvent({...ev, _replayIdx: idx}));\n window.refreshSessions = origRefresh;\n gsap.to('#activity-log', {opacity:1, duration:0.4});\n gsap.to('#ctrl', {opacity:1, duration:0.4, delay:0.2});\n}\n\n// ── SSE event stream ───────────────────────────────────────────────────────────\nlet evtSource = null;\nfunction connectSSE() {\n if (evtSource) evtSource.close();\n evtSource = new EventSource('/api/mastermind-stream');\n evtSource.onmessage = (e) => {\n try {\n const ev = JSON.parse(e.data);\n handleLiveEvent(ev);\n } catch (_) {}\n };\n evtSource.onerror = () => {\n const dot = document.getElementById('l-dot');\n if (dot) dot.classList.remove('on');\n const st = document.getElementById('l-status');\n if (st) st.textContent = 'RECONNECTING';\n showStatusBanner('SSE disconnected — reconnecting in 4s');\n setTimeout(connectSSE, 4000);\n };\n}\n\n// ── Session sidebar ────────────────────────────────────────────────────────────\nlet currentSessionId = null;\n let currentSessionObj = null;\n\nlet activeProjectFilter = '';\n\nfunction applyProjectFilter(val) {\n activeProjectFilter = val;\n refreshSessions();\n}\n\nasync function refreshSessions() {\n try {\n const url = activeProjectFilter\n ? `/api/mastermind/sessions?project=${encodeURIComponent(activeProjectFilter)}`\n : '/api/mastermind/sessions';\n const res = await fetch(url);\n const sessions = await res.json();\n // Populate project filter options\n const sel = document.getElementById('proj-filter');\n if (sel) {\n const projects = [...new Set(sessions.map(s => s.project).filter(Boolean))];\n const current = sel.value;\n sel.innerHTML = '<option value=\"\">ALL PROJECTS</option>' +\n projects.map(p => {\n const name = p.split('/').pop();\n return `<option value=\"${p}\" ${p===current?'selected':''}>${name}</option>`;\n }).join('');\n }\n renderSessions(sessions);\n } catch (_) {}\n}\n\nfunction renderSessions(sessions) {\n const wrap = document.getElementById('sb-sessions');\n const noSess = document.getElementById('sb-no-sessions');\n if (!sessions || !sessions.length) {\n if (noSess) noSess.style.display = 'block';\n const items = wrap.querySelectorAll('.sess-item');\n items.forEach(i => i.remove());\n return;\n }\n if (noSess) noSess.style.display = 'none';\n // Remove old items\n wrap.querySelectorAll('.sess-item').forEach(el => el.remove());\n sessions.forEach(s => {\n const item = document.createElement('div');\n item.className = 'sess-item' + (s.status === 'running' ? ' running' : '') + (s.id === currentSessionId ? ' active' : '');\n const ts = new Date(s.ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});\n const date = new Date(s.ts).toLocaleDateString([], {month:'short',day:'numeric'});\n const elapsed = s.endTs ? ((s.endTs - s.ts)/1000).toFixed(0)+'s' : (s.status==='running'?'RUNNING…':'?');\n const projName = s.project ? s.project.split('/').pop() : '';\n item.innerHTML = `\n ${projName ? `<div class=\"sess-project\">◈ ${projName}</div>` : ''}\n <div class=\"sess-ts\">${date} ${ts} · ${elapsed}</div>\n <div class=\"sess-prompt\">${s.prompt||'(no prompt)'}</div>\n <div class=\"sess-badges\">\n <span class=\"sess-badge ${s.status==='running'?'running-badge':''}\">${s.status||'?'}</span>\n ${(s.domains||[]).slice(0,4).map(d=>`<span class=\"sess-badge\">${d}</span>`).join('')}\n ${(s.domains||[]).length>4?`<span class=\"sess-badge\">+${s.domains.length-4}</span>`:''}\n <a class=\"sess-trace-link\" href=\"/api/mastermind/session/${s.id}/trace\" target=\"_blank\" title=\"View raw trace\" onclick=\"event.stopPropagation()\">trace↗</a>\n </div>`;\n item.addEventListener('click', () => {\n wrap.querySelectorAll('.sess-item').forEach(x=>x.classList.remove('active'));\n item.classList.add('active');\n currentSessionId = s.id;\n currentSessionObj = s;\n openSessionDetail(s);\n replaySessionGraph(s.events||[]);\n });\n wrap.appendChild(item);\n });\n}\n\n// ── Detail panel ───────────────────────────────────────────────────────────────\nfunction openDomainDetail(d) {\n const panel = document.getElementById('detail-panel');\n document.getElementById('dp-emoji').textContent = d.emoji || '◈';\n document.getElementById('dp-title').textContent = `DOMAIN · ${d.label}`;\n const body = document.getElementById('dp-body');\n // Gather events from current session for this domain\n const sessEvts = (currentSessionObj && currentSessionObj.events) ? currentSessionObj.events : [];\n const domEvts = sessEvts.filter(e => e.domain === d.domain || e.domain === (d.label||'').toLowerCase());\n const spawnEvts = domEvts.filter(e => e.type === 'agent:spawn');\n const artifacts = domEvts.flatMap(e => e.artifacts || []);\n // Also collect child agent nodes\n const agentNodes = [];\n nodes.forEach(n => { if (n.parentId === d.id) agentNodes.push(n); });\n body.innerHTML = `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">DOMAIN INFO</div>\n <div class=\"dp-event\"><span class=\"ev-type\" style=\"color:${d.color}\">${d.emoji||'◈'} ${d.label}</span></div>\n ${d.cmd ? `<div class=\"dp-event\">Command: <span style=\"color:#7080c0\">${d.cmd}</span></div>` : ''}\n <div class=\"dp-event\">Status: <span style=\"color:${d.done?'#40e880':'#28c068'}\">${d.done?'COMPLETE':'RUNNING'}</span></div>\n <div class=\"dp-event\">Agents spawned: <span style=\"color:${d.color}\">${agentNodes.length}</span></div>\n </div>\n ${agentNodes.length > 0 ? `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">AGENTS</div>\n ${agentNodes.map(a => `<div class=\"dp-event\"><span class=\"ev-type\" style=\"color:${a.color||d.color}\">${a.emoji||'◈'} ${a.label}</span></div>`).join('')}\n </div>` : ''}\n ${spawnEvts.length > 0 ? `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">TASKS</div>\n ${spawnEvts.map(e => `<div class=\"dp-event\" style=\"color:#506080;font-size:8px;white-space:normal;word-break:break-word;line-height:1.5\">${e.agent ? '<b>'+e.agent+'</b>: ' : ''}${(e.task||'').slice(0,120)}</div>`).join('')}\n </div>` : ''}\n ${artifacts.length > 0 ? `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">ARTIFACTS</div>\n ${artifacts.map(a => `<div class=\"dp-artifact\">📄 ${a}</div>`).join('')}\n </div>` : ''}\n ${domEvts.length > 0 ? `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">RECENT EVENTS</div>\n ${domEvts.slice(-8).map(e => {\n const ts = new Date(e.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});\n return `<div class=\"dp-event\"><span class=\"ev-ts\">${ts}</span> <span class=\"ev-type\" style=\"color:${d.color}\">${e.type}</span>${e.cmd?' '+e.cmd:e.agent?' '+e.agent:''}</div>`;\n }).join('')}\n </div>` : ''}\n `;\n panel.classList.add('open');\n}\n\nasync function openSessionDetail(s) {\n const panel = document.getElementById('detail-panel');\n document.getElementById('dp-emoji').textContent = '📋';\n document.getElementById('dp-title').textContent = 'SESSION DETAIL';\n const body = document.getElementById('dp-body');\n body.innerHTML = '<div style=\"color:#303060;font-size:9px;padding:8px\">Loading…</div>';\n panel.classList.add('open');\n try {\n const res = await fetch(`/api/mastermind/session/${s.id}`);\n const full = await res.json();\n if (!full) { body.innerHTML = '<div style=\"color:#303060;font-size:9px\">Session not found.</div>'; return; }\n const ts = new Date(full.ts).toLocaleString();\n const elapsed = full.endTs ? ((full.endTs - full.ts)/1000).toFixed(1)+'s' : 'running';\n const evts = full.events || [];\n const domainSet = full.domains || [];\n body.innerHTML = `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">OVERVIEW</div>\n <div class=\"dp-event\">Started: <span style=\"color:#6060a0\">${ts}</span></div>\n <div class=\"dp-event\">Duration: <span style=\"color:#6060a0\">${elapsed}</span></div>\n <div class=\"dp-event\">Status: <span style=\"color:${full.status==='complete'?'#40e880':full.status==='running'?'#28c068':'#f87171'}\">${full.status||'?'}</span></div>\n <div class=\"dp-event\">Domains: <span style=\"color:#8080c0\">${domainSet.join(', ')||'—'}</span></div>\n </div>\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">PROMPT</div>\n <div class=\"dp-event\" style=\"color:oklch(58% 0.09 186);word-break:break-all;white-space:normal;line-height:1.6\">${full.prompt||'—'}</div>\n </div>\n ${domainSet.length ? `\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">ACTIVE DOMAINS</div>\n ${domainSet.map(did => {\n const color = DOMAIN_COLORS[did] || '#8080c0';\n const emoji = DOMAIN_EMOJIS[did] || '◈';\n const label = (did||'').toUpperCase();\n return `<div class=\"dp-event\"><span style=\"color:${color}\">${emoji} ${label}</span></div>`;\n }).join('')}\n </div>` : ''}\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">EVENT TIMELINE (${evts.length})</div>\n ${evts.map(e => {\n const et = new Date(e.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});\n const color = e.domain ? (DOMAIN_COLORS[e.domain] || '#6060a0') : '#6060a0';\n let detail = '';\n if (e.type === 'session:start') detail = `<span style=\"color:#5050a0;font-size:8px;word-break:break-all\">${e.prompt||''}</span>`;\n else if (e.type === 'domain:dispatch') detail = `<span style=\"color:#5060a0;font-size:8px\">${e.cmd||''}</span>`;\n else if (e.type === 'agent:spawn') detail = `<span style=\"color:#507090;font-size:8px\">agent: <b>${e.agent||''}</b> — ${(e.task||'').slice(0,50)}</span>`;\n else if (e.type === 'intercom') detail = `<span style=\"color:#506070;font-size:8px\">${e.from||'?'} → ${e.to||'?'}: ${e.msg||''}</span>`;\n else if (e.type === 'domain:complete') {\n const arts = (e.artifacts||[]).map(a=>`<span style=\"color:#407050;font-size:7px\">📄 ${a}</span>`).join(' ');\n detail = `<span style=\"color:#406050;font-size:8px\">status: ${e.status||'?'}</span>${arts?' '+arts:''}`;\n }\n else if (e.type === 'session:complete') detail = `<span style=\"color:#405080;font-size:8px\">domains: ${(e.domains||[]).join(', ')}</span>`;\n return `<div class=\"dp-event\" style=\"flex-direction:column;align-items:flex-start;gap:1px\"><div><span class=\"ev-ts\">${et}</span> <span class=\"ev-type\" style=\"color:${color}\">${e.type}</span>${e.domain?' <span style=\"color:#404060;font-size:8px\">['+e.domain+']</span>':''}</div>${detail?'<div style=\"padding-left:4px\">'+detail+'</div>':''}</div>`;\n }).join('')}\n </div>\n <div class=\"dp-section\">\n <div class=\"dp-section-title\">EXPORT</div>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap\">\n <a class=\"dp-export-btn\" href=\"/api/mastermind/session/${full.id}/trace\" target=\"_blank\">📄 View Trace</a>\n <button class=\"dp-export-btn\" onclick=\"downloadSession('${full.id}')\">⬇ Download JSON</button>\n </div>\n </div>\n `;\n } catch(err) {\n body.innerHTML = `<div style=\"color:#a03030;font-size:9px\">${err.message}</div>`;\n }\n}\n\nfunction closeDetail() {\n document.getElementById('detail-panel').classList.remove('open');\n currentSessionId = null;\n document.querySelectorAll('.sess-item').forEach(x=>x.classList.remove('active'));\n}\n\nasync function downloadSession(id) {\n const res = await fetch(`/api/mastermind/session/${id}`);\n const data = await res.json();\n const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = `${id}.json`;\n a.click();\n URL.revokeObjectURL(a.href);\n}\n\n// ── Live data polling for status bar ──────────────────────────────────────────\nasync function pollStatus() {\n try {\n const res = await fetch('/api/data');\n if (!res.ok) return;\n const data = await res.json();\n const active = !!data?.swarm?.activity?.swarm?.active;\n const dot = document.getElementById('l-dot');\n dot.classList.toggle('on', active);\n document.getElementById('l-status').textContent = active ? 'LIVE' : 'IDLE';\n const n = data?.swarm?.state?.agentPlan?.length || 0;\n document.getElementById('l-agents').textContent = n + ' agent' + (n!==1?'s':'');\n // Highlight last routed domain\n const route = data?.hooks?.lastRoute || '';\n if (route && !isMovieMode) {\n const hit = DOMAINS.find(d => route.toLowerCase().includes(d.id));\n if (hit) {\n gsap.to(`#gr-${hit.id}`, { opacity:0.85, attr:{r:52}, duration:0.35 });\n gsap.to(`#gr-${hit.id}`, { opacity:0.2, attr:{r:44}, duration:1.8, delay:0.35 });\n }\n }\n } catch (_) {}\n}\n\n\nfunction showStatusBanner(msg) {\n let b = document.getElementById('status-banner');\n if (!b) {\n b = document.createElement('div'); b.id = 'status-banner';\n b.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:5px 14px;background:oklch(24% 0.05 186);border-bottom:1px solid oklch(68% 0.18 186 / 0.35);color:oklch(70% 0.05 186);font-size:9px;letter-spacing:1.5px;text-align:center;z-index:9999;transition:opacity 0.5s;pointer-events:none;';\n document.body.appendChild(b);\n }\n b.textContent = msg; b.style.opacity = '1';\n clearTimeout(b._t); b._t = setTimeout(() => { b.style.opacity = '0'; }, 5000);\n}\n\n// ── Bootstrap ──────────────────────────────────────────────────────────────────\nconnectSSE();\nrefreshSessions();\npollStatus();\nfetch('/api/git-user').then(r=>r.json()).then(u=>{\n if (u.name) document.getElementById('git-user-name').textContent = u.name;\n if (u.cwd) {\n const parts = u.cwd.replace(/\\\\/g, '/').split('/');\n document.getElementById('git-cwd-name').textContent = parts.slice(-2).join('/');\n document.getElementById('git-cwd-name').title = u.cwd;\n }\n}).catch(()=>{});\nsetInterval(pollStatus, 4000);\nsetInterval(refreshSessions, 8000);\n\n// Set initial live mode banner\ndocument.getElementById('mode-banner').classList.add('live-mode');\n</script>\n</body>\n</html>\n";
@@ -337,7 +370,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
337
370
  for (const { f, mtime } of sessionFiles) {
338
371
  const fp = path.join(projectClaudeDir, f);
339
372
  const id = f.replace('.jsonl', '');
340
- let lastPrompt = '', summaries = [], totalDurationMs = 0, totalMessages = 0, firstTs = null, lastTs = null;
373
+ let lastPrompt = '', summaries = [], totalDurationMs = 0, totalMessages = 0, firstTs = null, lastTs = null, totalCost = 0, toolCalls = 0, userMessages = 0, cacheReadTokens = 0, totalInputTokens = 0;
374
+ const modelBreakdown = {};
341
375
  try {
342
376
  const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
343
377
  let pendingCompact = false;
@@ -345,6 +379,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
345
379
  let e; try { e = JSON.parse(line); } catch { continue; }
346
380
  if (e.timestamp) { if (!firstTs) firstTs = e.timestamp; lastTs = e.timestamp; }
347
381
  if (e.type === 'last-prompt' && e.lastPrompt) lastPrompt = e.lastPrompt;
382
+ if (e.type === 'user') userMessages++;
348
383
  if (e.type === 'system' && e.subtype === 'compact_boundary') pendingCompact = true;
349
384
  if (pendingCompact && e.type === 'user') {
350
385
  const msg = e.message || {};
@@ -356,13 +391,31 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
356
391
  if (m) summaries.push({ ts: e.timestamp, text: m[1].trim() });
357
392
  pendingCompact = false;
358
393
  }
394
+ if (e.type === 'assistant') {
395
+ const msg = e.message || {};
396
+ for (const block of (msg.content || [])) {
397
+ if (block && block.type === 'tool_use') toolCalls++;
398
+ }
399
+ if (msg.usage && msg.model) {
400
+ const c = _sjCalcCost(msg.model, msg.usage);
401
+ totalCost += c;
402
+ const mk = msg.model.replace(/@.*$/, '').replace(/-\d{8}$/, '');
403
+ if (!modelBreakdown[mk]) modelBreakdown[mk] = { calls: 0, cost: 0 };
404
+ modelBreakdown[mk].calls++;
405
+ modelBreakdown[mk].cost += c;
406
+ cacheReadTokens += (msg.usage.cache_read_input_tokens || 0);
407
+ totalInputTokens += (msg.usage.input_tokens || 0)
408
+ + (msg.usage.cache_creation_input_tokens || 0)
409
+ + (msg.usage.cache_read_input_tokens || 0);
410
+ }
411
+ }
359
412
  if (e.type === 'system' && e.subtype === 'turn_duration') {
360
413
  totalDurationMs += e.durationMs || 0;
361
414
  if ((e.messageCount || 0) > totalMessages) totalMessages = e.messageCount;
362
415
  }
363
416
  }
364
417
  } catch {}
365
- sessions.push({ id, mtime, firstTs, lastTs, lastPrompt, summaries, totalDurationMs, totalMessages, file: fp });
418
+ sessions.push({ id, mtime, firstTs, lastTs, lastPrompt, summaries, totalDurationMs, totalMessages, totalCost, toolCalls, userMessages, cacheReadTokens, totalInputTokens, modelBreakdown, file: fp });
366
419
  }
367
420
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
368
421
  res.end(JSON.stringify({ sessions }));
@@ -429,6 +482,58 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
429
482
  return;
430
483
  }
431
484
 
485
+ // ------------------------------------------------------- GET /api/tool-errors
486
+ if (req.method === 'GET' && url === '/api/tool-errors') {
487
+ try {
488
+ const qs = new URL(req.url, 'http://localhost').searchParams;
489
+ const dir = qs.get('dir') || projectDir || process.cwd();
490
+ const d = path.resolve(dir || process.cwd());
491
+ const slug = d.replace(/\//g, '-');
492
+ const projectClaudeDir = path.join(os.homedir(), '.claude', 'projects', slug);
493
+ let sessionFiles = [];
494
+ try {
495
+ sessionFiles = fs.readdirSync(projectClaudeDir)
496
+ .filter(f => f.endsWith('.jsonl'))
497
+ .map(f => { try { return { f, mtime: fs.statSync(path.join(projectClaudeDir, f)).mtimeMs }; } catch { return null; } })
498
+ .filter(Boolean).sort((a,b) => b.mtime - a.mtime).slice(0, 10);
499
+ } catch {}
500
+ // tool_use id → name map, then count is_error:true tool_result per tool name
501
+ const errorCounts = {}, totalCounts = {};
502
+ for (const { f } of sessionFiles) {
503
+ const fp = path.join(projectClaudeDir, f);
504
+ try {
505
+ const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
506
+ const toolIdMap = {};
507
+ for (const line of lines) {
508
+ let e; try { e = JSON.parse(line); } catch { continue; }
509
+ if (e.type === 'assistant') {
510
+ for (const b of (e.message?.content || [])) {
511
+ if (b && b.type === 'tool_use') { toolIdMap[b.id] = b.name; totalCounts[b.name] = (totalCounts[b.name] || 0) + 1; }
512
+ }
513
+ }
514
+ if (e.type === 'user') {
515
+ for (const b of (e.message?.content || [])) {
516
+ if (b && b.type === 'tool_result' && b.is_error) {
517
+ const name = toolIdMap[b.tool_use_id] || '?';
518
+ errorCounts[name] = (errorCounts[name] || 0) + 1;
519
+ }
520
+ }
521
+ }
522
+ }
523
+ } catch {}
524
+ }
525
+ const errors = Object.entries(errorCounts)
526
+ .map(([tool, count]) => ({ tool, count, total: totalCounts[tool] || count }))
527
+ .sort((a,b) => b.count - a.count);
528
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
529
+ res.end(JSON.stringify({ errors }));
530
+ } catch (err) {
531
+ res.writeHead(500, { 'Content-Type': 'application/json' });
532
+ res.end(JSON.stringify({ error: err.message }));
533
+ }
534
+ return;
535
+ }
536
+
432
537
  // ------------------------------------------------------- GET /api/projects
433
538
  if (req.method === 'GET' && url === '/api/projects') {
434
539
  try {