@monoes/monomindcli 1.10.33 → 1.10.35

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.
Files changed (56) hide show
  1. package/.claude/helpers/intelligence.cjs +3 -2
  2. package/.claude/helpers/session.cjs +2 -1
  3. package/dist/src/browser/browser.d.ts.map +1 -1
  4. package/dist/src/browser/browser.js +16 -12
  5. package/dist/src/browser/browser.js.map +1 -1
  6. package/dist/src/browser/cdp.d.ts +1 -1
  7. package/dist/src/browser/cdp.d.ts.map +1 -1
  8. package/dist/src/browser/cdp.js +4 -2
  9. package/dist/src/browser/cdp.js.map +1 -1
  10. package/dist/src/browser/find.d.ts +1 -0
  11. package/dist/src/browser/find.d.ts.map +1 -1
  12. package/dist/src/browser/find.js +12 -0
  13. package/dist/src/browser/find.js.map +1 -1
  14. package/dist/src/browser/har.d.ts +26 -0
  15. package/dist/src/browser/har.d.ts.map +1 -0
  16. package/dist/src/browser/har.js +130 -0
  17. package/dist/src/browser/har.js.map +1 -0
  18. package/dist/src/browser/index.d.ts +5 -0
  19. package/dist/src/browser/index.d.ts.map +1 -1
  20. package/dist/src/browser/index.js +5 -0
  21. package/dist/src/browser/index.js.map +1 -1
  22. package/dist/src/browser/network.d.ts +15 -0
  23. package/dist/src/browser/network.d.ts.map +1 -1
  24. package/dist/src/browser/network.js +54 -0
  25. package/dist/src/browser/network.js.map +1 -1
  26. package/dist/src/browser/profiler.d.ts +10 -0
  27. package/dist/src/browser/profiler.d.ts.map +1 -0
  28. package/dist/src/browser/profiler.js +55 -0
  29. package/dist/src/browser/profiler.js.map +1 -0
  30. package/dist/src/browser/record.d.ts +21 -0
  31. package/dist/src/browser/record.d.ts.map +1 -0
  32. package/dist/src/browser/record.js +48 -0
  33. package/dist/src/browser/record.js.map +1 -0
  34. package/dist/src/browser/snapshot.d.ts.map +1 -1
  35. package/dist/src/browser/snapshot.js +23 -2
  36. package/dist/src/browser/snapshot.js.map +1 -1
  37. package/dist/src/browser/trace.d.ts +10 -0
  38. package/dist/src/browser/trace.d.ts.map +1 -0
  39. package/dist/src/browser/trace.js +72 -0
  40. package/dist/src/browser/trace.js.map +1 -0
  41. package/dist/src/browser/types.d.ts +1 -0
  42. package/dist/src/browser/types.d.ts.map +1 -1
  43. package/dist/src/browser/types.js.map +1 -1
  44. package/dist/src/browser/vitals.d.ts +15 -0
  45. package/dist/src/browser/vitals.d.ts.map +1 -0
  46. package/dist/src/browser/vitals.js +116 -0
  47. package/dist/src/browser/vitals.js.map +1 -0
  48. package/dist/src/browser/wait.js +1 -1
  49. package/dist/src/browser/wait.js.map +1 -1
  50. package/dist/src/commands/browse.d.ts.map +1 -1
  51. package/dist/src/commands/browse.js +426 -23
  52. package/dist/src/commands/browse.js.map +1 -1
  53. package/dist/src/ui/.monomind/sessions/current.json +1 -1
  54. package/dist/src/ui/dashboard-v2.html +245 -40
  55. package/dist/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +1 -1
@@ -434,7 +434,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
434
434
  #app.ambient #metrics-pane,
435
435
  #app.ambient #replay-bar,
436
436
  #app.ambient #feed-recap,
437
- #app.ambient #feed-timeline { display:none !important; }
437
+ #app.ambient #feed-timeline,
438
+ #app.ambient #digest-card { display:none !important; }
438
439
  #app.ambient #main { background:var(--bg); }
439
440
  #app.ambient #view-now { height:100vh; }
440
441
  #app.ambient #feed-pane { border:none; }
@@ -504,6 +505,53 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
504
505
  .sess-row.diff-sel-a { background:oklch(60% 0.12 220 / 0.08); outline:1px solid oklch(60% 0.12 220 / 0.3); }
505
506
  .sess-row.diff-sel-b { background:oklch(72% 0.18 75 / 0.06); outline:1px solid oklch(72% 0.18 75 / 0.3); }
506
507
 
508
+ /* ── burn rate gauge ─────────────────────────────────────── */
509
+ .burn-gauge-wrap { margin-top:6px; }
510
+ .burn-gauge-row { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
511
+ .burn-gauge-label { font-size:10px; color:var(--text-lo); width:60px; flex-shrink:0; }
512
+ .burn-gauge-track { flex:1; height:6px; background:var(--surface-hi); border-radius:3px; overflow:hidden; }
513
+ .burn-gauge-fill { height:100%; border-radius:3px; transition:width 0.4s, background 0.4s; }
514
+ .burn-val { font-size:10px; font-family:var(--mono); color:var(--text-xs); white-space:nowrap; }
515
+ .burn-rate-ok { background:oklch(65% 0.15 150); }
516
+ .burn-rate-warn { background:oklch(72% 0.18 75); }
517
+ .burn-rate-hot { background:oklch(60% 0.18 25); }
518
+
519
+ /* ── swimlane timeline ────────────────────────────────────── */
520
+ #swimlane-wrap { padding:4px 0; }
521
+ .sw-row { display:flex; align-items:center; gap:6px; margin-bottom:3px; height:14px; }
522
+ .sw-lbl { font-size:10px; color:var(--text-xs); width:60px; flex-shrink:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; cursor:pointer; }
523
+ .sw-lbl:hover { color:var(--text-lo); }
524
+ .sw-track { flex:1; height:10px; background:var(--surface-hi); border-radius:3px; position:relative; overflow:hidden; }
525
+ .sw-bar { position:absolute; top:0; height:100%; border-radius:2px; opacity:0.8; }
526
+ .sw-gap { position:absolute; top:0; height:100%; background:var(--bg); width:2px; }
527
+
528
+ /* ── focus mode ──────────────────────────────────────────── */
529
+ #feed-pane.focus-mode .feed-entry.k-tool:not(.errored) { display:none; }
530
+ #feed-pane.focus-mode .feed-group { display:none; }
531
+ .focus-btn { font-size:10px; color:var(--text-lo); background:var(--surface); border:1px solid var(--border); border-radius:4px; padding:2px 7px; cursor:pointer; transition:color 0.15s, border-color 0.15s; line-height:1.4; white-space:nowrap; }
532
+ .focus-btn:hover { color:var(--text-hi); }
533
+ .focus-btn.on { color:oklch(60% 0.12 220); border-color:oklch(60% 0.12 220 / 0.5); }
534
+
535
+ /* ── session notes ───────────────────────────────────────── */
536
+ .sess-notes-wrap { margin-top:8px; }
537
+ .sess-notes-toggle { font-size:11px; color:var(--text-xs); background:none; border:none; cursor:pointer; padding:0; display:flex; align-items:center; gap:5px; }
538
+ .sess-notes-toggle:hover { color:var(--text-lo); }
539
+ .sess-notes-toggle.has-note { color:oklch(72% 0.14 220); }
540
+ .sess-notes-area { display:none; margin-top:6px; }
541
+ .sess-notes-area.open { display:block; }
542
+ textarea.sess-note-input { width:100%; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); color:var(--text-mid); font-size:12px; font-family:var(--sans); padding:6px 8px; resize:vertical; min-height:56px; outline:none; transition:border-color 0.1s; }
543
+ textarea.sess-note-input:focus { border-color:var(--accent); }
544
+ .sess-note-saved { font-size:10px; color:var(--text-xs); margin-top:3px; height:14px; }
545
+
546
+ /* ── export button ───────────────────────────────────────── */
547
+ .sess-copy-btn.dl { }
548
+
549
+ /* ── loop run history sparkline ──────────────────────────── */
550
+ .loop-sparkline { display:flex; gap:2px; align-items:flex-end; height:20px; margin-top:4px; }
551
+ .lsp-bar { width:5px; border-radius:2px 2px 0 0; background:var(--accent); opacity:0.6; min-height:3px; }
552
+ .lsp-bar.err { background:var(--red); opacity:0.8; }
553
+ .le-spark { display:flex; align-items:center; gap:8px; }
554
+
507
555
  /* ── budget cap ──────────────────────────────────────────── */
508
556
  #budget-modal { display:none; position:fixed; inset:0; z-index:200; background:oklch(5% 0 0 / 0.6); align-items:center; justify-content:center; }
509
557
  #budget-modal.open { display:flex; }
@@ -612,6 +660,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
612
660
  <button class="live-tail-btn" id="btn-live-tail" onclick="toggleLiveTail()" title="Toggle live tail (auto-scroll + 5s refresh)">⬤ Tail</button>
613
661
  <button class="sess-copy-btn" id="btn-replay" onclick="replayToggle()" title="Replay session event-by-event">⏵ Replay</button>
614
662
  <button class="sess-copy-btn" id="btn-copy-sess" onclick="copySession()" title="Copy session as markdown">⎘ Copy</button>
663
+ <button class="sess-copy-btn" id="btn-export-sess" onclick="exportSession()" title="Download session as .md file">⬇ Export</button>
664
+ <button class="focus-btn" id="btn-focus" onclick="toggleFocusMode()" title="Focus mode: show only user messages + errors">⊡ Focus</button>
615
665
  <button class="density-btn" id="btn-density" onclick="toggleDensity()" title="Toggle compact view">⊟</button>
616
666
  <button class="sess-btn" onclick="toggleFeedSearch()" title="Search in feed (/)">⌕</button>
617
667
  <button class="sess-btn" id="btn-prev-sess" onclick="prevSession()" title="Older session">‹</button>
@@ -688,6 +738,14 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
688
738
  <div class="m-group-title">Tool Usage</div>
689
739
  <div class="loading-txt">—</div>
690
740
  </div>
741
+ <div id="m-burn">
742
+ <div class="m-group-title">Burn Rate</div>
743
+ <div class="loading-txt">—</div>
744
+ </div>
745
+ <div id="m-swimlane">
746
+ <div class="m-group-title">Session Lanes</div>
747
+ <div class="loading-txt">—</div>
748
+ </div>
691
749
  </div>
692
750
  </div>
693
751
 
@@ -1082,6 +1140,7 @@ function renderFeedEvents(events, silent) {
1082
1140
  buildTimeline(filtered);
1083
1141
  buildBreakdownByName(filtered);
1084
1142
  buildMinimap(filtered);
1143
+ updateBurnRate(filtered);
1085
1144
  // session recap card
1086
1145
  buildRecap(filtered, allSessions[sessionIdx]);
1087
1146
  }
@@ -1354,7 +1413,7 @@ async function loadLoopMetrics() {
1354
1413
  const TWO_HOURS = 2 * 3600 * 1000;
1355
1414
  const now = Date.now();
1356
1415
  alertState.longLoops = loops
1357
- .filter(l => l.running !== false && l.startedAt && (now - new Date(l.startedAt).getTime()) > TWO_HOURS)
1416
+ .filter(l => l.status !== 'stopped' && l.status !== 'paused' && l.startedAt && (now - new Date(l.startedAt).getTime()) > TWO_HOURS)
1358
1417
  .map(l => (l.name || l.prompt || 'loop').split('--')[0].trim().slice(0, 30));
1359
1418
  updateAlerts();
1360
1419
 
@@ -1383,6 +1442,7 @@ function renderMiniSessions(sessions) {
1383
1442
  <div class="ms-meta">${relTime(s.lastTs || s.mtime)}</div>
1384
1443
  </div>`).join('');
1385
1444
  document.getElementById('m-sessions').innerHTML = `<div class="m-group-title">Recent Sessions</div>${items}`;
1445
+ buildSwimlane();
1386
1446
  }
1387
1447
 
1388
1448
  // ── projects ───────────────────────────────────────────────
@@ -1465,6 +1525,8 @@ async function renderSessions() {
1465
1525
  const autoTags = (allTags.sessionTags.get(s.id) || []).map(t => `<span class="sr-autotag">${esc(t)}</span>`).join('');
1466
1526
  const isStarred = bookmarks.has(s.id);
1467
1527
  const sData = JSON.stringify(s).replace(/'/g, '&#39;');
1528
+ const note = getSessNote(s.id);
1529
+ const hasNote = !!note;
1468
1530
  return `<div class="sess-row" data-sess-idx="${idx}" onclick="diffMode ? diffSelectSession(JSON.parse(this.dataset.sessData || '{}'), this) : jumpToSession('${esc(s.id)}')" data-sess-data='${sData}'>
1469
1531
  <div class="sr-top">
1470
1532
  <div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
@@ -1474,6 +1536,13 @@ async function renderSessions() {
1474
1536
  </div>
1475
1537
  <div class="sr-meta">${esc(meta)}</div>
1476
1538
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1539
+ <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1540
+ <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1541
+ <div class="sess-notes-area" id="snote-${esc(s.id)}">
1542
+ <textarea class="sess-note-input" rows="2" placeholder="Session note…" oninput="saveSessNote('${esc(s.id)}',this.value,this.closest('.sess-notes-wrap').querySelector('.sess-notes-toggle'),this.closest('.sess-row').querySelector('.sess-note-saved'))">${esc(note)}</textarea>
1543
+ <div class="sess-note-saved"></div>
1544
+ </div>
1545
+ </div>
1477
1546
  </div>`;
1478
1547
  }).join('');
1479
1548
  // prepend tag filter bar if there are common tags
@@ -1856,8 +1925,12 @@ function buildDigest() {
1856
1925
  const totalMsgs = todaySessions.reduce((a, s) => a + (s.userMessages || 0), 0);
1857
1926
  const longestMs = Math.max(...todaySessions.map(s => s.totalDurationMs || 0));
1858
1927
 
1859
- // gather top tools from today's sessions' tag keywords as themes
1860
- const themes = [...new Set(todaySessions.flatMap(s => s._tags || []))].slice(0, 3);
1928
+ // top tags from today's sessions using the auto-tag system
1929
+ const tagFreq = {};
1930
+ for (const s of todaySessions) {
1931
+ for (const t of (allTags.sessionTags.get(s.id) || [])) tagFreq[t] = (tagFreq[t] || 0) + 1;
1932
+ }
1933
+ const themes = Object.entries(tagFreq).sort((a,b) => b[1]-a[1]).slice(0, 3).map(e => e[0]);
1861
1934
 
1862
1935
  const stats = [
1863
1936
  `${todaySessions.length} session${todaySessions.length > 1 ? 's' : ''}`,
@@ -1886,7 +1959,8 @@ function buildMinimap(events) {
1886
1959
  const scroll = document.getElementById('feed-scroll');
1887
1960
  if (!track || !thumb || !scroll) return;
1888
1961
 
1889
- const tools = events.filter(ev => ev.kind === 'tool' || ev.kind === 'user');
1962
+ // feed renders newest-first (reversed), so reverse events to match visual order
1963
+ const tools = [...events].reverse().filter(ev => ev.kind === 'tool' || ev.kind === 'user');
1890
1964
  if (!tools.length) { track.innerHTML = ''; return; }
1891
1965
 
1892
1966
  const CAT_CLS = { file:'mp-file', bash:'mp-bash', agent:'mp-agent', mcp:'mp-mcp', user:'mp-user' };
@@ -2052,6 +2126,168 @@ function renderDiff() {
2052
2126
  cols.innerHTML = colHtml(0) + colHtml(1);
2053
2127
  }
2054
2128
 
2129
+ // ── feature 13: export session as .md file ────────────────
2130
+ async function exportSession() {
2131
+ const btn = document.getElementById('btn-export-sess');
2132
+ const sess = allSessions[sessionIdx];
2133
+ if (!sess?.file) return;
2134
+ try {
2135
+ btn.textContent = '…';
2136
+ const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=300');
2137
+ const events = data.events || [];
2138
+ const lines = [
2139
+ `# Session: ${sess.lastPrompt || sess.id}`,
2140
+ `> ${new Date(sess.lastTs || sess.mtime).toLocaleString()}`,
2141
+ sess.totalCost != null ? `> Cost: $${sess.totalCost.toFixed(2)}` : '',
2142
+ sess.totalDurationMs ? `> Duration: ${fmtDur(sess.totalDurationMs)}` : '',
2143
+ '',
2144
+ ].filter(s => s !== null);
2145
+ for (const ev of events) {
2146
+ if (ev.kind === 'user' && ev.text?.trim()) {
2147
+ lines.push(`\n## ${ev.text.trim().slice(0, 80)}`);
2148
+ if (ev.ts) lines.push(`_${new Date(ev.ts).toLocaleTimeString()}_`);
2149
+ } else if (ev.kind === 'tool') {
2150
+ const label = ev.label || ev.name || ev.cat;
2151
+ lines.push(`- \`${ev.name || ev.cat}\`${label ? ': ' + label : ''}${ev._errored ? ' ⚠ error' : ''}`);
2152
+ }
2153
+ }
2154
+ lines.push('', `---`, `_Exported from monomind dashboard_`);
2155
+ const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
2156
+ const a = document.createElement('a');
2157
+ a.href = URL.createObjectURL(blob);
2158
+ a.download = `session-${sess.id.slice(0, 8)}.md`;
2159
+ a.click();
2160
+ URL.revokeObjectURL(a.href);
2161
+ btn.textContent = '✓ Saved';
2162
+ setTimeout(() => { btn.textContent = '⬇ Export'; }, 1800);
2163
+ } catch (err) {
2164
+ btn.textContent = '✕ Error';
2165
+ setTimeout(() => { btn.textContent = '⬇ Export'; }, 1500);
2166
+ }
2167
+ }
2168
+
2169
+ // ── feature 14: focus mode ────────────────────────────────
2170
+ let focusMode = false;
2171
+ function toggleFocusMode() {
2172
+ focusMode = !focusMode;
2173
+ document.getElementById('feed-pane').classList.toggle('focus-mode', focusMode);
2174
+ document.getElementById('btn-focus').classList.toggle('on', focusMode);
2175
+ document.getElementById('btn-focus').title = focusMode ? 'Exit focus mode' : 'Focus mode: user messages + errors only';
2176
+ }
2177
+
2178
+ // ── feature 15: session notes ─────────────────────────────
2179
+ const NOTES_KEY = 'mm-sess-notes';
2180
+ function getAllNotes() {
2181
+ try { return JSON.parse(localStorage.getItem(NOTES_KEY) || '{}'); } catch { return {}; }
2182
+ }
2183
+ function getSessNote(id) { return getAllNotes()[id] || ''; }
2184
+ function saveSessNote(id, text, toggleBtn, savedEl) {
2185
+ const all = getAllNotes();
2186
+ if (text.trim()) all[id] = text; else delete all[id];
2187
+ localStorage.setItem(NOTES_KEY, JSON.stringify(all));
2188
+ if (toggleBtn) {
2189
+ toggleBtn.classList.toggle('has-note', !!text.trim());
2190
+ toggleBtn.textContent = '✎ ' + (text.trim() ? 'Note' : 'Add note');
2191
+ }
2192
+ if (savedEl) {
2193
+ savedEl.textContent = 'Saved';
2194
+ clearTimeout(savedEl._t);
2195
+ savedEl._t = setTimeout(() => { savedEl.textContent = ''; }, 1200);
2196
+ }
2197
+ }
2198
+ function toggleSessNote(id, btn) {
2199
+ const area = document.getElementById('snote-' + id);
2200
+ if (!area) return;
2201
+ const open = area.classList.toggle('open');
2202
+ if (open) { const ta = area.querySelector('textarea'); if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } }
2203
+ }
2204
+
2205
+ // ── feature 16: burn rate gauge ───────────────────────────
2206
+ let burnRateEvents = [];
2207
+ let burnRateTimer = null;
2208
+
2209
+ function updateBurnRate(events) {
2210
+ burnRateEvents = events;
2211
+ renderBurnGauge();
2212
+ }
2213
+
2214
+ function renderBurnGauge() {
2215
+ const el = document.getElementById('m-burn');
2216
+ if (!el) return;
2217
+ const tools = burnRateEvents.filter(ev => ev.kind === 'tool' && ev.ts);
2218
+ if (tools.length < 2) {
2219
+ el.innerHTML = '<div class="m-group-title">Burn Rate</div><div class="loading-txt" style="padding:4px 0">—</div>';
2220
+ return;
2221
+ }
2222
+ const now = Date.now();
2223
+ // calls in last 5 min, 15 min, 60 min
2224
+ const t5 = tools.filter(e => now - new Date(e.ts).getTime() < 300000).length;
2225
+ const t15 = tools.filter(e => now - new Date(e.ts).getTime() < 900000).length;
2226
+ const t60 = tools.filter(e => now - new Date(e.ts).getTime() < 3600000).length;
2227
+ const rate5 = (t5 / 5).toFixed(1); // calls/min
2228
+ const rate15 = (t15 / 15).toFixed(1);
2229
+ const rate60 = (t60 / 60).toFixed(1);
2230
+ const maxRate = 20; // calls/min considered "hot"
2231
+ const pct = v => Math.min(100, Math.round((parseFloat(v) / maxRate) * 100));
2232
+ const cls = v => parseFloat(v) < 4 ? 'burn-rate-ok' : parseFloat(v) < 10 ? 'burn-rate-warn' : 'burn-rate-hot';
2233
+ el.innerHTML = `<div class="m-group-title">Burn Rate <span style="font-size:10px;color:var(--text-xs);font-weight:400">calls/min</span></div>
2234
+ <div class="burn-gauge-wrap">
2235
+ <div class="burn-gauge-row"><div class="burn-gauge-label">5 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate5)}" style="width:${pct(rate5)}%"></div></div><div class="burn-val">${rate5}</div></div>
2236
+ <div class="burn-gauge-row"><div class="burn-gauge-label">15 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate15)}" style="width:${pct(rate15)}%"></div></div><div class="burn-val">${rate15}</div></div>
2237
+ <div class="burn-gauge-row"><div class="burn-gauge-label">60 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate60)}" style="width:${pct(rate60)}%"></div></div><div class="burn-val">${rate60}</div></div>
2238
+ </div>`;
2239
+ }
2240
+
2241
+ // ── feature 17: session swimlane ──────────────────────────
2242
+ function buildSwimlane() {
2243
+ const el = document.getElementById('m-swimlane');
2244
+ if (!el) return;
2245
+ const recent = allSessions.slice(0, 8);
2246
+ if (!recent.length) { el.innerHTML = '<div class="m-group-title">Session Lanes</div><div class="loading-txt" style="padding:4px 0">—</div>'; return; }
2247
+ const now = Date.now();
2248
+ const DAY = 86400000;
2249
+ const windowMs = DAY; // 24-hour window
2250
+ const windowStart = now - windowMs;
2251
+ // hues spread across sessions for visual distinction
2252
+ const LANE_HUES = [75, 200, 300, 150, 25, 220, 340, 120];
2253
+ const rows = recent.map((s, si) => {
2254
+ const start = s.firstTs || s.startTs || s.mtime || now;
2255
+ const startMs = typeof start === 'number' ? start : new Date(start).getTime();
2256
+ const dur = s.totalDurationMs || 60000;
2257
+ const endMs = startMs + dur;
2258
+ const leftPct = Math.max(0, Math.min(100, ((startMs - windowStart) / windowMs) * 100));
2259
+ const rightPct = Math.max(0, Math.min(100, ((now - endMs) / windowMs) * 100));
2260
+ const widthPct = Math.max(2, 100 - leftPct - rightPct);
2261
+ const hue = LANE_HUES[si % LANE_HUES.length];
2262
+ const color = `oklch(65% 0.14 ${hue})`;
2263
+ const name = (s.lastPrompt || s.id).slice(0, 16);
2264
+ return `<div class="sw-row">
2265
+ <div class="sw-lbl" onclick="jumpToSession('${esc(s.id)}')" title="${esc(s.lastPrompt || s.id)}">${esc(name)}</div>
2266
+ <div class="sw-track">
2267
+ <div class="sw-bar" style="left:${leftPct}%;width:${widthPct}%;background:${color}" title="${esc(name)} · ${fmtDur(dur)}"></div>
2268
+ </div>
2269
+ </div>`;
2270
+ }).join('');
2271
+ el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>`;
2272
+ }
2273
+
2274
+ // ── feature 18: loop run history ──────────────────────────
2275
+ function buildLoopSparkline(l) {
2276
+ // Synthesize a run history from currentRep if actual history unavailable
2277
+ const runHistory = l.runHistory || [];
2278
+ if (!runHistory.length && l.currentRep) {
2279
+ for (let i = 0; i < Math.min(l.currentRep, 10); i++) {
2280
+ runHistory.push({ ok: true });
2281
+ }
2282
+ }
2283
+ if (!runHistory.length) return '';
2284
+ const bars = runHistory.slice(-10).map(r => {
2285
+ const h = r.durationMs ? Math.max(4, Math.min(20, Math.round(r.durationMs / 2000))) : 10;
2286
+ return `<div class="lsp-bar${r.error || r.ok === false ? ' err' : ''}" style="height:${h}px" title="${r.error ? 'error' : 'ok'}${r.durationMs ? ' · ' + fmtDur(r.durationMs) : ''}"></div>`;
2287
+ }).join('');
2288
+ return `<div class="le-spark"><span style="font-size:10px;color:var(--text-xs)">last ${runHistory.slice(-10).length} runs</span><div class="loop-sparkline">${bars}</div></div>`;
2289
+ }
2290
+
2055
2291
  // ── loops ──────────────────────────────────────────────────
2056
2292
  async function renderLoops() {
2057
2293
  const el = document.getElementById('loops-content');
@@ -2065,13 +2301,13 @@ async function renderLoops() {
2065
2301
  return;
2066
2302
  }
2067
2303
  el.innerHTML = loops.map((l, idx) => {
2068
- const running = l.running !== false;
2304
+ const running = l.status !== 'stopped' && l.status !== 'paused';
2069
2305
  const name = l.name || (l.prompt || 'loop').split('--')[0].trim().slice(0, 60);
2070
2306
  const interval = l.interval || l.schedule || '';
2071
2307
  const fullPrompt = l.prompt || l.command || '';
2072
2308
  const startedAt = l.startedAt ? new Date(l.startedAt).toLocaleString() : '—';
2073
- const lastRun = l.lastRun ? relTime(l.lastRun) : (l.startedAt ? relTime(l.startedAt) : '—');
2074
- const runs = l.runCount != null ? l.runCount : '—';
2309
+ const lastRun = l.lastRunAt ? relTime(l.lastRunAt) : (l.startedAt ? relTime(l.startedAt) : '—');
2310
+ const runs = l.currentRep != null ? l.currentRep : '—';
2075
2311
  return `<div class="loop-row" onclick="toggleLoop(this)">
2076
2312
  <div class="loop-ico">↺</div>
2077
2313
  <div class="loop-body">
@@ -2087,6 +2323,7 @@ async function renderLoops() {
2087
2323
  <div class="le-row"><div class="le-lbl">Started</div><div class="le-val mono">${esc(startedAt)}</div></div>
2088
2324
  <div class="le-row"><div class="le-lbl">Last run</div><div class="le-val">${esc(lastRun)}</div></div>
2089
2325
  <div class="le-row"><div class="le-lbl">Run count</div><div class="le-val">${esc(String(runs))}</div></div>
2326
+ ${buildLoopSparkline(l)}
2090
2327
  </div>`;
2091
2328
  }).join('');
2092
2329
  } catch (err) {
@@ -2226,38 +2463,6 @@ function buildTimeline(events) {
2226
2463
  tl.innerHTML = segs.join('');
2227
2464
  }
2228
2465
 
2229
- // ── tool breakdown ──────────────────────────────────────────
2230
- const TB_COLORS = TL_COLORS;
2231
- const TB_ORDER = ['file','bash','mcp','agent','search','skill','task','mem','other'];
2232
-
2233
- function buildBreakdown(events) {
2234
- const counts = {};
2235
- for (const ev of events) {
2236
- if (ev.kind !== 'tool') continue;
2237
- const c = ev.cat || 'other';
2238
- counts[c] = (counts[c] || 0) + 1;
2239
- }
2240
- const total = Object.values(counts).reduce((a, b) => a + b, 0);
2241
- if (!total) {
2242
- document.getElementById('m-breakdown').innerHTML =
2243
- '<div class="m-group-title">Tool Usage</div><div class="loading-txt" style="padding:6px 0">—</div>';
2244
- return;
2245
- }
2246
- const rows = TB_ORDER.filter(c => counts[c])
2247
- .sort((a, b) => counts[b] - counts[a])
2248
- .map(c => {
2249
- const pct = Math.round(counts[c] / total * 100);
2250
- const color = TB_COLORS[c] || TB_COLORS.other;
2251
- return `<div class="tb-row">
2252
- <div class="tb-lbl">${c}</div>
2253
- <div class="tb-bar-wrap"><div class="tb-bar" style="width:${pct}%;background:${color}"></div></div>
2254
- <div class="tb-count">${counts[c]}</div>
2255
- </div>`;
2256
- }).join('');
2257
- document.getElementById('m-breakdown').innerHTML =
2258
- `<div class="m-group-title">Tool Usage</div><div class="m-breakdown">${rows}</div>`;
2259
- }
2260
-
2261
2466
  // ── error jump ──────────────────────────────────────────────
2262
2467
  function jumpToErrors() {
2263
2468
  // find first errored feed entry and scroll to it