claude-finops 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -5,7 +5,7 @@ const {fmtUSD, fmtNum, fmtInt, fmtPct, seriesVar} = C;
5
5
  const S = {
6
6
  view: 'hygiene',
7
7
  opts: null,
8
- filter: {start: null, end: null, agents: [], models: [], projects: [], categories: [],
8
+ filter: {start: null, end: null, models: [], projects: [], categories: [],
9
9
  include_sandbox: true, min_cost: null, min_tokens: null},
10
10
  range: '30d',
11
11
  metric: 'cost',
@@ -34,7 +34,7 @@ const qs = () => {
34
34
  const f = S.filter, p = new URLSearchParams();
35
35
  if (f.start) p.set('start', f.start);
36
36
  if (f.end) p.set('end', f.end);
37
- for (const k of ['agents', 'models', 'projects', 'categories'])
37
+ for (const k of ['models', 'projects', 'categories'])
38
38
  if (f[k].length) p.set(k, f[k].join(','));
39
39
  if (!f.include_sandbox) p.set('include_sandbox', '0');
40
40
  if (f.min_cost) p.set('min_cost', f.min_cost);
@@ -55,7 +55,7 @@ async function api(route, extra = '') {
55
55
  const bust = () => S.cache.clear();
56
56
 
57
57
  /* ============================ formatting helpers ============================ */
58
- const naLabel = () => `Unavailable from connected ${agentWord()} data`;
58
+ const naLabel = () => 'Unavailable from Claude Code data';
59
59
  const NA = () => `<span class="na">${esc(naLabel())}</span>`;
60
60
  const BADGE = {actual: '<span class="badge">Actual</span>',
61
61
  estimated: '<span class="badge est">Estimated</span>',
@@ -132,13 +132,12 @@ const NAV = [
132
132
  ['hygiene', 'gauge', 'Context hygiene'],
133
133
  ['overview', 'dashboard', 'Executive overview'],
134
134
  ['advisor', 'bulb', 'What should I do?'],
135
- ['agents', 'bot', 'Agents'],
136
135
  ['live', 'activity', 'Running sessions'],
137
136
  ['scorecard', 'award', 'FinOps scorecard'],
138
137
  ]],
139
138
  ['Usage', [
140
139
  ['usage', 'barChart', 'Usage timeline'],
141
- ['burn', 'flame', 'Burn rate & limits', 'priced'],
140
+ ['burn', 'flame', 'Burn rate & limits'],
142
141
  ['models', 'cpu', 'Model analysis'],
143
142
  ['context', 'layers', 'Context & cache'],
144
143
  ]],
@@ -148,23 +147,23 @@ const NAV = [
148
147
  ['prompts', 'message', 'Prompt explorer'],
149
148
  ['rankings', 'sortDesc', 'Cost rankings'],
150
149
  ['categories', 'tag', 'Prompt intelligence'],
151
- ['developer', 'code', 'Developer activity', 'priced'],
150
+ ['developer', 'code', 'Developer activity'],
152
151
  ]],
153
152
  ['Optimize', [
154
- ['diagnose', 'scan', 'Why so many tokens?', 'priced'],
155
- ['attribution', 'users', 'Who used the tokens', 'priced'],
156
- ['subagents', 'bot', 'Subagent models', 'claude'],
153
+ ['diagnose', 'scan', 'Why so many tokens?'],
154
+ ['attribution', 'users', 'Who used the tokens'],
155
+ ['subagents', 'bot', 'Subagent models'],
157
156
  ['waste', 'trash', 'Waste detection'],
158
- ['freemodels', 'gift', 'Free models', 'claude'],
157
+ ['freemodels', 'gift', 'Free models'],
159
158
  ['jev', 'sparkles', 'Jev (fast decisions)'],
160
- ['compare', 'scale', 'Compare models', 'priced'],
161
- ['toolkit', 'wrench', 'Skills & MCP', 'claude'],
159
+ ['compare', 'scale', 'Compare models'],
160
+ ['toolkit', 'wrench', 'Skills & MCP'],
162
161
  ['recommendations', 'listChecks', 'Recommendations'],
163
162
  ['anomalies', 'zap', 'Anomalies'],
164
163
  ]],
165
164
  ['Plan', [
166
- ['forecast', 'trendUp', 'Forecast', 'priced'],
167
- ['budgets', 'wallet', 'Budgets', 'priced'],
165
+ ['forecast', 'trendUp', 'Forecast'],
166
+ ['budgets', 'wallet', 'Budgets'],
168
167
  ['cloud', 'cloud', 'Billed vs local', 'cloud'],
169
168
  ['exports', 'download', 'Export & data'],
170
169
  ]],
@@ -238,6 +237,7 @@ const IP = {
238
237
  terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3M12 15h6"/>',
239
238
  message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
240
239
  sortDesc: '<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/>',
240
+ sortAsc: '<path d="M11 5h4M11 9h7M11 13h10M3 7l3-3 3 3M6 6v14"/>',
241
241
  tag: '<path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z"/><circle cx="7.5" cy="7.5" r="1.5"/>',
242
242
  code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
243
243
  scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="11.5" cy="11.5" r="3.5"/><path d="m17 17-3-3"/>',
@@ -278,7 +278,7 @@ function shell() {
278
278
  <div class="sub">Command Center</div></div>
279
279
  <nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
280
280
  items.map(([id, ic, label]) =>
281
- `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
281
+ `<a href="/${id}" data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
282
282
  </nav>
283
283
  <div class="who" id="who"></div></aside>
284
284
  <div class="main">
@@ -298,7 +298,7 @@ function shell() {
298
298
  <button class="iconbtn sq" id="theme"></button>
299
299
  <button class="iconbtn sq" id="refresh" title="Reload this page's numbers from the warehouse. To pick up new sessions, use Sync" aria-label="Reload view">${ICON.reload}</button>
300
300
  </div>
301
- <button class="act sync" id="sync" title="Re-read every agent's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
301
+ <button class="act sync" id="sync" title="Re-read Claude Code's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
302
302
  </div>
303
303
  <div class="filters" id="filters"></div>
304
304
  </header>
@@ -306,7 +306,7 @@ function shell() {
306
306
  </div></div>`;
307
307
 
308
308
  document.querySelectorAll('.nav a').forEach(a =>
309
- a.onclick = () => go(a.dataset.view));
309
+ a.onclick = e => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return; e.preventDefault(); go(a.dataset.view); });
310
310
  $('#tour-btn').onclick = () => tourForView();
311
311
  $('#theme').onclick = () => {
312
312
  const cur = document.documentElement.getAttribute('data-theme');
@@ -320,12 +320,7 @@ function shell() {
320
320
  themeLabel();
321
321
  $('#refresh').onclick = () => { bust(); render(); };
322
322
  maskLabel();
323
- $('#mask').onclick = () => {
324
- MASKED = !MASKED;
325
- try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
326
- maskLabel(); closeDrawer(); render();
327
- if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
328
- };
323
+ $('#mask').onclick = () => toggleMask();
329
324
  $('#recent-btn').onclick = () => toggleRecent();
330
325
  updateChip();
331
326
  $('#sync').onclick = runSync;
@@ -397,6 +392,17 @@ async function scopeStrip() {
397
392
  cell('Active days', fmtInt(o.active_days));
398
393
  }
399
394
 
395
+ // From a drawer, the drawer is drawn again in the new state instead of being closed.
396
+ function toggleMask(fromDrawer) {
397
+ MASKED = !MASKED;
398
+ try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
399
+ const dr = fromDrawer && S.drawerRoute;
400
+ maskLabel();
401
+ if (!dr) closeDrawer();
402
+ render();
403
+ if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
404
+ if (dr) openDrawerRoute(dr);
405
+ }
400
406
  function maskLabel() {
401
407
  const b = $('#mask');
402
408
  // The icon shows the current state: an open eye while prompts are visible.
@@ -452,12 +458,7 @@ function applyRange(r) {
452
458
  function filterBar() {
453
459
  const f = S.filter, o = S.opts;
454
460
  const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
455
- const ag = o.agents || [];
456
461
  $('#filters').innerHTML = `
457
- ${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
458
- ${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
459
- title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
460
- <button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
461
462
  <div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
462
463
  ${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
463
464
  data-range="${k}">${l}</button>`).join('')}</div>
@@ -481,17 +482,6 @@ function filterBar() {
481
482
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
482
483
  b.onclick = e => { if (b.dataset.range === 'custom') { e.stopPropagation(); return openCustom(b); }
483
484
  applyRange(b.dataset.range); bust(); render(); });
484
- $('#filters').querySelectorAll('[data-agent]').forEach(b => b.onclick = e => {
485
- const id = b.dataset.agent;
486
- // click = only this agent; Cmd/Ctrl/Shift-click = add or remove it (multi-agent view)
487
- if (id === '*') f.agents = ag.map(a => a.id);
488
- else if (e.metaKey || e.ctrlKey || e.shiftKey)
489
- f.agents = f.agents.includes(id) ? f.agents.filter(x => x !== id) : [...f.agents, id];
490
- else f.agents = [id];
491
- if (!f.agents.length) f.agents = [id === '*' ? ag[0].id : id];
492
- try { localStorage.setItem('finops-agents', JSON.stringify(f.agents)); } catch (_) {}
493
- bust(); render();
494
- });
495
485
  $('#sbx').onclick = () => { f.include_sandbox = !f.include_sandbox; bust(); render(); };
496
486
  const clr = $('#clr'); if (clr) clr.onclick = () => {
497
487
  f.models = []; f.projects = []; f.categories = []; f.min_cost = null; f.min_tokens = null;
@@ -561,7 +551,10 @@ function openCustom(btn) {
561
551
  function closeDrawer() {
562
552
  document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
563
553
  S.drawerStack = [];
554
+ if (S.drawerRoute) { S.drawerRoute = null; syncURL(true); }
564
555
  }
556
+ // An open drawer is part of the URL (?session=…), so refresh and Back bring it back.
557
+ function setDrawerRoute(k, id) { S.drawerRoute = {k, id: String(id)}; syncURL(true); }
565
558
  function drawer(title, bodyHtml, sub) {
566
559
  C.hideTip();
567
560
  document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
@@ -569,10 +562,14 @@ function drawer(title, bodyHtml, sub) {
569
562
  const d = h(`<aside class="drawer"><header>
570
563
  <h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
571
564
  <span class="spacer"></span>
565
+ <button class="iconbtn" data-mask aria-pressed="${MASKED}"
566
+ title="${MASKED ? 'Prompt text is hidden. Click to show it' : 'Hide prompt text and session titles'}"
567
+ >${I(MASKED ? 'eyeOff' : 'eye')} ${MASKED ? 'Show prompts' : 'Hide prompts'}</button>
572
568
  <button class="iconbtn" data-x>Close ${I('x')}</button></header>
573
569
  <div class="content">${bodyHtml}</div></aside>`);
574
570
  scrim.onclick = closeDrawer;
575
571
  d.querySelector('[data-x]').onclick = closeDrawer;
572
+ d.querySelector('[data-mask]').onclick = () => toggleMask(true);
576
573
  document.body.append(scrim, d);
577
574
  return d;
578
575
  }
@@ -594,6 +591,7 @@ const wholeNote = (what, x) => {
594
591
  };
595
592
  async function openPrompt(id) {
596
593
  const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
594
+ setDrawerRoute('prompt', id);
597
595
  const p = await fetch(`/api/prompt/${id}?${qs()}`).then(r => r.json());
598
596
  d.querySelector('.content').innerHTML = `${wholeNote('prompt', p)}
599
597
  <div class="grid g4">
@@ -648,6 +646,7 @@ async function openPrompt(id) {
648
646
 
649
647
  async function openSession(id) {
650
648
  const d = drawer('Session detail', '<div class="loading">Loading…</div>', esc(shortId(id)));
649
+ setDrawerRoute('session', id);
651
650
  const s = await fetch(`/api/session/${encodeURIComponent(id)}?${qs()}`).then(r => r.json());
652
651
  const cont = d.querySelector('.content');
653
652
  cont.innerHTML = `${wholeNote('session', s)}
@@ -746,7 +745,7 @@ VIEWS.overview = async (page) => {
746
745
  ${alloc.configured
747
746
  ? kpi('Remaining', fmtUSD(alloc.remaining),
748
747
  `${alloc.days_until_limit ?? '—'} days at current burn`, {badge: BADGE.estimated})
749
- : kpi('Remaining allowance', null, `No plan limit exposed by ${agentWord()} data`)}
748
+ : kpi('Remaining allowance', null, 'No plan limit exposed by Claude Code data')}
750
749
  ${kpi('Period forecast', fcExp == null ? null : fmtUSD(fcExp),
751
750
  `${bp.remaining_days} days left in ${bp.start} → ${bp.end}`, {badge: BADGE.forecast})}
752
751
  </div>
@@ -837,7 +836,7 @@ VIEWS.overview = async (page) => {
837
836
  C.gauge(gEl, {pct: pctOfPeriod, status: 'healthy', label: 'of billing period elapsed'});
838
837
  $('#burnnotes', page).innerHTML = `<div class="stack">
839
838
  <div class="item"><div class="hd">No plan allowance available</div>
840
- <div class="dt">${agentWord()} data does not expose plan limits, remaining credits,
839
+ <div class="dt">Claude Code data does not expose plan limits, remaining credits,
841
840
  or message allowances. Set <span class="mono">limits.monthly_cost_allowance_usd</span>
842
841
  in <span class="mono">config/settings.json</span> to unlock usage-vs-limit,
843
842
  days-until-limit and limit-date projections.</div></div>
@@ -914,7 +913,7 @@ VIEWS.overview = async (page) => {
914
913
  page._focusData = d;
915
914
  wireFocus(page);
916
915
  }).catch(() => {});
917
- if (hasClaude()) fetch('/api/act_now').then(r => r.json()).then(d => {
916
+ fetch('/api/act_now').then(r => r.json()).then(d => {
918
917
  if (!page.isConnected || !(d.items || []).length) return;
919
918
  const el = h(actNowStrip(d.items));
920
919
  page.insertBefore(el, page.firstChild);
@@ -951,7 +950,7 @@ function wireActNow(root, items) {
951
950
  b.disabled = true;
952
951
  try {
953
952
  if (what === 'compact') {
954
- const r = await sessionAction(it.pid, 'compact', {agent: 'claude'});
953
+ const r = await sessionAction(it.pid, 'compact');
955
954
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
956
955
  say(r.ok ? r.message : `Failed: ${r.error}`, r.ok);
957
956
  b.disabled = false; return;
@@ -984,7 +983,7 @@ async function jumpTo(view, anchor) {
984
983
  /* ---------- row actions: Resume (copy) and, for a running session, Compact ---------- */
985
984
  async function liveSessions() {
986
985
  try {
987
- const d = await fetch('/api/live?agents=claude').then(r => r.json());
986
+ const d = await fetch('/api/live').then(r => r.json());
988
987
  return new Map((d.sessions || []).filter(x => x.signalable && x.pid).map(x => [x.session_id, x.pid]));
989
988
  } catch { return new Map(); }
990
989
  }
@@ -1007,7 +1006,7 @@ function wireRowActs(host) {
1007
1006
  if (!armed(b)) return;
1008
1007
  b.disabled = true;
1009
1008
  try {
1010
- const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
1009
+ const r = await sessionAction(+b.dataset.compact, 'compact');
1011
1010
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
1012
1011
  b.innerHTML = r.ok ? `${I('check')} Sent` : 'Failed'; b.title = r.ok ? r.message : r.error;
1013
1012
  } catch (err) { b.textContent = 'Failed'; b.title = err.message; }
@@ -1061,7 +1060,7 @@ VIEWS.advisor = async (page) => {
1061
1060
  ${kpi('Spend involved', fmtUSD(r.actual_cost_usd), null, {small: 1, badge: BADGE.estimated})}
1062
1061
  </div>
1063
1062
  ${r.alternatives && r.alternatives.length ? `<div class="dt" style="margin-top:6px">
1064
- <b>Your options${r.agent ? ` within ${esc(r.agent)}` : ''}</b> — pick the trade-off you want:</div>
1063
+ <b>Your options</b> — pick the trade-off you want:</div>
1065
1064
  ${table([
1066
1065
  {h: 'Model', f: a => esc(a.name) + (a.suggested
1067
1066
  ? ' <span class="badge rec">suggested</span>' : '')},
@@ -1377,7 +1376,7 @@ VIEWS.projects = async (page) => {
1377
1376
  ${card('Project FinOps table', '<div id="pt"></div>', {badge: BADGE.estimated,
1378
1377
  hint: 'Project → Session → Prompt drill-down',
1379
1378
  footer: 'Project identity comes from the working directory recorded in each transcript. '
1380
- + `Repository/remote metadata is not present in ${agentWord()} data.`})}`;
1379
+ + 'Repository/remote metadata is not present in Claude Code data.'})}`;
1381
1380
  C.barsH($('#pb', page), {rows: rows.slice(0, 18), label: r => r.name, value: r => r.cost,
1382
1381
  color: seriesVar(2), onClick: r => { S.filter.projects = [String(r.project_id)];
1383
1382
  bust(); go('sessions'); },
@@ -1621,7 +1620,7 @@ VIEWS.categories = async (page) => {
1621
1620
  ${card('Cost by activity', `<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
1622
1621
  <div class="chart" id="cd"></div><div style="flex:1;min-width:220px" class="legend"
1623
1622
  id="cl" style="flex-direction:column;align-items:flex-start"></div></div>`,
1624
- {badge: BADGE.estimated, hint: `where your ${agentWord()} budget goes`})}
1623
+ {badge: BADGE.estimated, hint: 'where your Claude Code budget goes'})}
1625
1624
  ${card('Prompts by activity', '<div class="chart" id="cb"></div>', {badge: BADGE.actual})}
1626
1625
  </div>
1627
1626
  ${card('Activity breakdown', '<div id="ct"></div>', {badge: BADGE.estimated,
@@ -2124,23 +2123,25 @@ async function drawPrompts(box, sid) {
2124
2123
 
2125
2124
  VIEWS.live = async (page) => {
2126
2125
  const [res, usage0] = await Promise.all([
2127
- fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
2126
+ fetch('/api/live').then(r => r.json()), // never cached: always live
2128
2127
  fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
2129
2128
  const list = res.sessions || [];
2130
2129
  const liveActions = res.actions || ['interrupt', 'close', 'kill'];
2131
2130
  const canInterrupt = liveActions.includes('interrupt');
2132
- const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
2133
2131
  const busy = list.filter(x => x.status === 'busy');
2134
2132
  const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
2133
+ // Idle, but used in the last 50 minutes: probably still in use, so closing it asks louder.
2134
+ const RECENT_S = 50 * 60;
2135
+ const recentlyUsed = x => x.status !== 'busy' && x.last_write_s != null && x.last_write_s < RECENT_S;
2135
2136
  page.innerHTML = `
2136
- ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
2137
+ ${usage0 ? card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
2137
2138
  {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
2138
- actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`})}
2139
+ actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`}) : ''}
2139
2140
  <div class="grid g4">
2140
2141
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
2141
2142
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
2142
2143
  ${kpi('Largest live context', fmtNum(Math.max(0, ...list.map(x => x.context || 0))), 'Re-read on every step')}
2143
- ${kpi('Spent by live sessions', fmtUSD(list.reduce((a, x) => a + (x.est_cost_usd || 0), 0)), 'Since each started (priced agents only)', {badge: BADGE.estimated})}
2144
+ ${kpi('Spent by live sessions', fmtUSD(list.reduce((a, x) => a + (x.est_cost_usd || 0), 0)), 'Since each started', {badge: BADGE.estimated})}
2144
2145
  </div>
2145
2146
  <div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
2146
2147
  which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
@@ -2148,16 +2149,21 @@ VIEWS.live = async (page) => {
2148
2149
  Windows console; anywhere else it lands on your clipboard to paste).
2149
2150
  <b>Close</b> exits the session cleanly; copy its resume command to bring it back.
2150
2151
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
2151
- Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
2152
- written in the last 20 minutes. Cursor sessions live inside the IDE and can't be stopped from here.
2153
2152
  <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
2153
+ ${list.some(x => x.signalable) ? `<div class="live-bulk">
2154
+ <label><input type="checkbox" id="live-all"> Select all idle</label>
2155
+ <button class="act warn" id="live-close-sel" disabled>${I('stop')} Close selected</button>
2156
+ <span class="note">Sessions that are working are never closed in bulk.</span>
2157
+ <span class="live-msg" id="live-bulk-msg" role="status"></span></div>` : ''}
2154
2158
  <div class="stack">${list.map((x, i) => `
2155
- <div class="item sev-${sevOf(x)}" data-i="${i}">
2156
- <div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
2157
- <span class="pill">${esc(agentName(x.agent))}</span>
2159
+ <div class="item sev-${sevOf(x)}${x.status === 'busy' ? ' working' : ''}" data-i="${i}">
2160
+ <div class="hd">${x.signalable ? `<input type="checkbox" class="live-pick" aria-label="Select for bulk close"${x.status === 'busy'
2161
+ ? ' disabled title="Working now: bulk close skips it"' : ''}>` : ''}
2162
+ ${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
2158
2163
  <span class="sname">${esc((!MASKED && x.name) || shortId(x.session_id))}</span> <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
2164
+ ${x.status === 'busy' ? '<span class="status healthy">Working now</span>' : ''}
2159
2165
  ${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
2160
- ${x.agent === 'claude' ? limitBadge(x.session_id) : ''}
2166
+ ${limitBadge(x.session_id)}
2161
2167
  <span class="spacer"></span>
2162
2168
  <span style="font-variant-numeric:tabular-nums">${x.context == null ? 'context not recorded' : fmtNum(x.context) + ' context'} · ${fmtInt(x.steps)} steps${x.est_cost_usd == null ? '' : ' · ' + fmtUSD(x.est_cost_usd)}</span></div>
2163
2169
  <div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
@@ -2168,17 +2174,17 @@ VIEWS.live = async (page) => {
2168
2174
  : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
2169
2175
  <div class="live-actions">
2170
2176
  ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>${I('pause')} Interrupt</button>` : ''}
2171
- ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2177
+ <button class="act" data-a="compact" title="Types /compact into that session's terminal">${I('compact')} Compact</button>
2172
2178
  <button class="act warn" data-a="close">${I('stop')} Close session</button>
2173
- <button class="act danger" data-a="kill">${I('x')} Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
2179
+ <button class="act danger" data-a="kill">${I('x')} Force kill</button>` : '<span class="note">No matching process found: stop it in its terminal.</span>'}
2174
2180
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
2175
- ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
2176
- ${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
2177
- ${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-pr="1">' + I('message') + ' Prompts</button>' : ''}
2178
- ${x.agent === 'claude' && x.signalable ? '<button class="act ghost" data-rn="1">' + I('tag') + ' Rename</button>' : ''}
2181
+ <button class="act ghost" data-ho="1">${I('handover')} Hand over / split</button>
2182
+ ${x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
2183
+ ${x.session_id ? '<button class="act ghost" data-pr="1">' + I('message') + ' Prompts</button>' : ''}
2184
+ ${x.signalable ? '<button class="act ghost" data-rn="1">' + I('tag') + ' Rename</button>' : ''}
2179
2185
  <span class="live-msg"></span>
2180
2186
  </div>
2181
- ${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
2187
+ ${`<div class="handover" hidden>
2182
2188
  <div class="dt">Starts fresh session(s) in <code>${esc(x.cwd)}</code>, seeded with a brief built from this
2183
2189
  transcript (goal, recent requests, task list, files changed, last status). No tokens are spent building it.</div>
2184
2190
  <textarea rows="3" placeholder="Leave empty to continue the same work in one new session.&#10;Or split it: one sub-task per line, one new session each (max 8)."></textarea>
@@ -2190,9 +2196,9 @@ VIEWS.live = async (page) => {
2190
2196
  </div>
2191
2197
  <div class="ho-out note"></div>
2192
2198
  </div>`}
2193
- ${x.agent === 'claude' && x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
2194
- ${x.agent === 'claude' && x.session_id ? `<div class="handover promptbox" hidden></div>` : ''}
2195
- ${x.agent === 'claude' && x.signalable ? `<div class="handover renamebox" hidden>
2199
+ ${x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
2200
+ ${x.session_id ? `<div class="handover promptbox" hidden></div>` : ''}
2201
+ ${x.signalable ? `<div class="handover renamebox" hidden>
2196
2202
  <div class="dt">Types <code>/rename &lt;name&gt;</code> into that session's terminal, so Claude Code saves the
2197
2203
  name itself (in herdr, the agents sidebar gets the same name). If the terminal can't be reached, the command lands on your clipboard to paste.</div>
2198
2204
  <div class="live-actions">
@@ -2200,10 +2206,58 @@ VIEWS.live = async (page) => {
2200
2206
  <button class="act rn-go">Rename</button>
2201
2207
  <span class="live-msg rn-msg" role="status"></span>
2202
2208
  </div></div>` : ''}
2203
- </div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
2209
+ </div>`).join('') || '<div class="empty">No Claude Code sessions are running</div>'}</div>`;
2204
2210
  $('#live-refresh', page).onclick = () => render();
2211
+ const all = $('#live-all', page), closeSel = $('#live-close-sel', page), bulkMsg = $('#live-bulk-msg', page);
2212
+ if (all) {
2213
+ const picks = () => [...page.querySelectorAll('.live-pick')];
2214
+ const chosen = () => picks().filter(c => c.checked && !c.disabled).map(c => list[+c.closest('.item').dataset.i]);
2215
+ const sync = () => {
2216
+ const n = chosen().length, open = picks().filter(c => !c.disabled);
2217
+ closeSel.disabled = !n;
2218
+ closeSel.classList.remove('armed');
2219
+ closeSel.innerHTML = `${I('stop')} Close selected${n ? ` (${n})` : ''}`;
2220
+ all.checked = open.length > 0 && open.every(c => c.checked);
2221
+ all.indeterminate = n > 0 && !all.checked;
2222
+ };
2223
+ all.onchange = () => { picks().forEach(c => { if (!c.disabled) c.checked = all.checked; }); sync(); };
2224
+ picks().forEach(c => c.onchange = sync);
2225
+ closeSel.onclick = async () => {
2226
+ const sel = chosen();
2227
+ if (!sel.length) return;
2228
+ if (!closeSel.classList.contains('armed')) { // two clicks, like each row's Close
2229
+ const recent = sel.filter(recentlyUsed).length;
2230
+ closeSel.classList.add('armed');
2231
+ closeSel.textContent = `Click again to close ${sel.length} session${sel.length > 1 ? 's' : ''}` + (recent
2232
+ ? `. ${recent} of them ${recent > 1 ? 'were' : 'was'} used in the last 50 min, maybe still in use` : '');
2233
+ setTimeout(() => { if (closeSel.classList.contains('armed')) sync(); }, recent ? 6000 : 4000);
2234
+ return;
2235
+ }
2236
+ closeSel.classList.remove('armed'); closeSel.disabled = true; closeSel.textContent = 'Closing…';
2237
+ // Status is read again right before closing: a session that started working since the
2238
+ // page was drawn is skipped, not stopped mid-task.
2239
+ let now = [];
2240
+ try { now = (await fetch('/api/live').then(r => r.json())).sessions || []; } catch {}
2241
+ const status = new Map(now.map(s => [s.pid, s.status]));
2242
+ let closed = 0, skipped = 0;
2243
+ const failed = [];
2244
+ for (const x of sel) {
2245
+ if (!status.has(x.pid)) continue; // already gone
2246
+ if (status.get(x.pid) === 'busy') { skipped++; continue; }
2247
+ try {
2248
+ const r = await sessionAction(x.pid, 'close');
2249
+ if (r.ok) closed++; else failed.push(`${(!MASKED && x.name) || shortId(x.session_id)}: ${r.error}`);
2250
+ } catch (e) { failed.push(e.message); }
2251
+ }
2252
+ bulkMsg.className = 'live-msg ' + (failed.length ? 'err' : 'ok');
2253
+ bulkMsg.textContent = [`Closed ${closed}.`, skipped ? `Skipped ${skipped} that started working.` : '',
2254
+ failed.length ? `Failed: ${failed.join('; ')}` : ''].filter(Boolean).join(' ');
2255
+ setTimeout(() => render(), failed.length ? 4000 : 1500);
2256
+ };
2257
+ sync();
2258
+ }
2205
2259
  const ub = $('#usage-refresh', page);
2206
- ub.onclick = async () => {
2260
+ if (ub) ub.onclick = async () => {
2207
2261
  ub.disabled = true; ub.textContent = 'Reading…';
2208
2262
  try {
2209
2263
  const u = await fetch('/api/usage?refresh=1').then(r => r.json());
@@ -2271,14 +2325,20 @@ VIEWS.live = async (page) => {
2271
2325
  if (!b.classList.contains('armed')) {
2272
2326
  row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
2273
2327
  b.dataset.label = label; b.classList.add('armed');
2328
+ const stopping = ['close', 'kill'].includes(b.dataset.a);
2329
+ const loud = stopping && (x.status === 'busy' || recentlyUsed(x));
2274
2330
  b.textContent = x.hosts_dashboard && !['interrupt', 'compact'].includes(b.dataset.a)
2275
- ? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)' : `Click again to ${b.dataset.a}`;
2276
- setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
2331
+ ? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)'
2332
+ : stopping && x.status === 'busy' ? 'It is working right now. Click again to stop it mid-task'
2333
+ : loud ? `Used ${dur(x.last_write_s)} ago, maybe still in use. Click again to ${b.dataset.a} anyway`
2334
+ : `Click again to ${b.dataset.a}`;
2335
+ setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } },
2336
+ loud ? 6000 : 4000);
2277
2337
  return;
2278
2338
  }
2279
2339
  b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
2280
2340
  try {
2281
- const r = await sessionAction(pid, b.dataset.a, {agent: x.agent});
2341
+ const r = await sessionAction(pid, b.dataset.a);
2282
2342
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
2283
2343
  msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
2284
2344
  msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
@@ -2316,8 +2376,7 @@ const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : '—';
2316
2376
 
2317
2377
  VIEWS.diagnose = async (page) => {
2318
2378
  const d = await api('diagnose');
2319
- const isCl = (d.agent || 'claude') === 'claude', md = d.vocab?.md || 'CLAUDE.md';
2320
- const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
2379
+ const md = d.vocab?.md || 'CLAUDE.md';
2321
2380
  PB.length = 0;
2322
2381
  const t = d.total || {}, tr = d.trend || {};
2323
2382
  const sevChip = s => dot(s === 'high' ? 'red' : s === 'medium' ? 'yellow' : 'grey');
@@ -2349,7 +2408,7 @@ VIEWS.diagnose = async (page) => {
2349
2408
  ${x.fixes.map(t => `<div class="dt">• ${esc(t)}</div>`).join('')}${pb(x.playbook)}</div>`).join('')
2350
2409
  || '<div class="empty">No session passed 150K context</div>'}</div>`,
2351
2410
  {badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
2352
- <div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
2411
+ <div id="dx-mem"></div>${card(`Add to ${md} / memory (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
2353
2412
  <div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
2354
2413
  <div class="hd">${I(x.kind === 'security' ? 'lock' : x.kind === 'template' ? 'cog' : x.kind === 'reference' ? 'link' : 'note')}
2355
2414
  ${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
@@ -2373,14 +2432,14 @@ VIEWS.diagnose = async (page) => {
2373
2432
  ${pb(r.playbook)}</div>`).join('')
2374
2433
  || '<div class="empty">Nothing stands out</div>'}</div>`, {badge: BADGE.recommendation})}
2375
2434
  ${card(`Projects: ${esc(d.vocab?.name || 'Claude')} config health`, `<div id="dx-proj"></div>`,
2376
- {badge: BADGE.recommendation, hint: isCl ? 'CLAUDE.md, memory, .claude/settings.json and MCP checked on disk now' : `${md} checked on disk now`, flush: 1})}
2377
- ${isCl ? card('Projects: does it need a harness?', `<div id="dx-harness" class="stack"></div>`,
2378
- {badge: BADGE.recommendation, hint: 'Harness = CLAUDE.md, permissions, hooks, skills, subagents. Need is judged from your usage in each project'}) : ''}
2435
+ {badge: BADGE.recommendation, hint: 'CLAUDE.md, memory, .claude/settings.json and MCP checked on disk now', flush: 1})}
2436
+ ${card('Projects: does it need a harness?', `<div id="dx-harness" class="stack"></div>`,
2437
+ {badge: BADGE.recommendation, hint: 'Harness = CLAUDE.md, permissions, hooks, skills, subagents. Need is judged from your usage in each project'})}
2379
2438
  <div id="dx-issues"></div>
2380
- ${card(`Global config (${gdir})`, `<div class="stack">
2439
+ ${card('Global config (~/.claude)', `<div class="stack">
2381
2440
  <div class="dt">Default model: <b>${esc(d.global.default_model || 'not set')}</b> ·
2382
2441
  MCP servers: ${esc(d.global.mcp_servers.join(', ') || 'none')} ·
2383
- ${isCl ? `Custom agents: ${esc(d.global.custom_agents.join(', ') || 'none')}` : ''}</div>
2442
+ Custom agents: ${esc(d.global.custom_agents.join(', ') || 'none')}</div>
2384
2443
  ${d.global.files.map(x => `<div class="dt">${esc(x.path)} — ~${fmtInt(x.tokens)} tokens</div>`).join('')}
2385
2444
  ${d.global.issues.map(i => `<div class="item sev-${i.severity}"><div class="hd">${sevChip(i.severity)} ${esc(i.title)}</div>
2386
2445
  <div class="dt">${esc(i.fix)}</div>${pb(i.playbook)}</div>`).join('')}</div>`)}
@@ -2391,9 +2450,9 @@ VIEWS.diagnose = async (page) => {
2391
2450
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
2392
2451
  {h: 'Avg ctx', num: 1, f: r => fmtNum(r.avg_context)},
2393
2452
  {h: md, f: r => r.claude_md.paths.length ? `~${fmtInt(r.claude_md.tokens)} tok` : '<span class="na">missing</span>'},
2394
- ...(isCl ? [{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`}] : []),
2453
+ {h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`},
2395
2454
  {h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
2396
- ...(isCl ? [{h: 'Harness', f: r => harnessChip(r.harness)}] : []),
2455
+ {h: 'Harness', f: r => harnessChip(r.harness)},
2397
2456
  {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : I('check')},
2398
2457
  ];
2399
2458
  page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
@@ -2628,7 +2687,7 @@ async function drawKeys(page) {
2628
2687
  </div>`).join('');
2629
2688
  const done = async (pid, text) => {
2630
2689
  S.opts = await fetch('/api/options').then(r => r.json());
2631
- applyAgentChrome(); // Billed vs local appears once a key exists
2690
+ applyChrome(); // Billed vs local appears once a key exists
2632
2691
  await drawKeys(page);
2633
2692
  const ok = $('#key-' + pid + '-ok', page); if (ok) ok.textContent = text;
2634
2693
  };
@@ -2853,7 +2912,7 @@ VIEWS.budgets = async (page) => {
2853
2912
  : `Last 30 days: ${fmtNum(SG.tokens_30d)} tokens.`,
2854
2913
  'g-tokens': v => !sizes.length ? '' : v ? overShare(v) : `Half your sessions stay under ${fmtNum(pctile(.5))} tokens.`,
2855
2914
  };
2856
- const planHint = `Your own figure, from your plan or invoice. ${esc(agentWord())} data does not include it.`;
2915
+ const planHint = 'Your own figure, from your plan or invoice. Claude Code data does not include it.';
2857
2916
  const stepVal = sg.step_pct ?? 25;
2858
2917
  // Does any per-session budget exist (global or a project override)? The hook needs one.
2859
2918
  const anyBudget = !!sg.session_tokens || [sg.projects, sg.sessions].some(m => Object.values(m || {}).some(o => o.session_tokens));
@@ -3022,7 +3081,7 @@ VIEWS.budgets = async (page) => {
3022
3081
  const gp = Object.entries(sg.projects || {}).map(([path, o]) =>
3023
3082
  ({path, off: !!o.off, tokens: o.session_tokens == null ? '' : o.session_tokens.toLocaleString('en-US')}));
3024
3083
  const known = [...new Map((S.opts.projects || [])
3025
- .filter(p => p.path && (p.agent || 'claude') === 'claude' && !p.is_sandbox)
3084
+ .filter(p => p.path && !p.is_sandbox)
3026
3085
  .map(p => [p.path, p])).values()].sort((a, b) => a.name.localeCompare(b.name));
3027
3086
  const drawProjects = () => {
3028
3087
  $('#g-projects', page).innerHTML = gp.length ? gp.map((o, i) => `
@@ -3129,7 +3188,7 @@ VIEWS.budgets = async (page) => {
3129
3188
  VIEWS.settings = async (page) => {
3130
3189
  page.innerHTML = card('API keys', `<div class="cfg">
3131
3190
  <div class="fld-hint" style="margin-bottom:8px">An API key is a password that lets this dashboard ask
3132
- Anthropic or Cursor for your company's bill. You only need one for the <b>Billed vs local</b> page,
3191
+ Anthropic for your company's bill. You only need one for the <b>Billed vs local</b> page,
3133
3192
  which compares what was billed with what this computer recorded. That page shows up in the sidebar
3134
3193
  once a key is saved. Skip this if you don't use it. Keys stay on this computer, in a file only you
3135
3194
  can read. They are never shown again.</div>
@@ -3216,11 +3275,11 @@ VIEWS.developer = async (page) => {
3216
3275
  {h: 'Touches', num: 1, f: r => fmtInt(r.touches)},
3217
3276
  ], d.files), {badge: BADGE.actual})}
3218
3277
  </div>
3219
- ${card(`Not available from ${esc(agentWord())} data`, `<dl class="kv">
3278
+ ${card('Not available from Claude Code data', `<dl class="kv">
3220
3279
  ${Object.entries(u).map(([k, v]) => `<dt>${esc(k.replace(/_/g, ' '))}</dt>
3221
3280
  <dd class="na">${esc(v)}</dd>`).join('')}
3222
3281
  </dl>`, {badge: '<span class="badge na">Unavailable</span>',
3223
- footer: 'Agent transcripts record which files a tool call targeted, but not diff sizes, commits, PRs or issue linkage. Correlating spend with commits or PRs would require a separate git/GitHub data source, which this dashboard deliberately does not fabricate.'})}`;
3282
+ footer: 'Claude Code transcripts record which files a tool call targeted, but not diff sizes, commits, PRs or issue linkage. Correlating spend with commits or PRs would require a separate git/GitHub data source, which this dashboard deliberately does not fabricate.'})}`;
3224
3283
  C.barsH($('#rb', page), {rows: d.cost_per_repository.slice(0, 12), label: r => r.repository,
3225
3284
  value: r => r.cost, color: seriesVar(2)});
3226
3285
  C.barsH($('#tb', page), {rows: d.tools.slice(0, 14), label: r => r.name, value: r => r.calls,
@@ -3314,7 +3373,7 @@ VIEWS.exports = async (page) => {
3314
3373
  <div class="dt">Read directly from your transcripts: token counts, timestamps, models,
3315
3374
  tool calls, file paths, session and project identity.</div></div>
3316
3375
  <div class="item"><div class="hd">${BADGE.estimated} Estimated</div>
3317
- <div class="dt">Derived: every dollar figure. ${agentWord()} data contains no billed
3376
+ <div class="dt">Derived: every dollar figure. Claude Code data contains no billed
3318
3377
  amounts, so cost = tokens × <span class="mono">config/pricing.json</span>.</div></div>
3319
3378
  <div class="item"><div class="hd">${BADGE.forecast} Forecast</div>
3320
3379
  <div class="dt">Projected from history. Assumes recent patterns continue; it is not a
@@ -3410,29 +3469,22 @@ async function updateNavBadges() {
3410
3469
  const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
3411
3470
  if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
3412
3471
  try {
3413
- const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
3472
+ const [d, w, a] = await Promise.all([api('diagnose'), api('waste'), api('anomalies')]);
3414
3473
  if (d) { const hid = focusHidden(), f = focusItems(d).filter(i => !hid.has(i.key)), urgent = f.filter(i => i.lvl === 1).length;
3415
3474
  set('diagnose', urgent || f.length, urgent ? 1 : 2); }
3416
3475
  const hw = w.findings.filter(x => x.severity === 'high').length;
3417
3476
  set('waste', hw || w.findings.length, hw ? 1 : 2);
3418
3477
  set('anomalies', (a.anomalies || []).length, 2);
3419
- const lv = await fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
3478
+ const lv = await fetch('/api/live').then(r => r.json());
3420
3479
  const heavy = (lv.sessions || []).filter(x => x.severity !== 'ok').length;
3421
3480
  set('live', (lv.sessions || []).length, heavy ? 1 : 2);
3422
3481
  } catch { /* badges are a hint; never block the page */ }
3423
3482
  }
3424
3483
 
3425
- /* ---------- agent-aware chrome ---------- */
3426
- // What the current agent selection can show: Claude-only pages need Claude Code in the
3427
- // selection; cost pages need at least one agent whose usage is priced.
3428
- const selAgents = () => (S.opts?.agents || []).filter(a => S.filter.agents.includes(a.id));
3429
- const hasClaude = () => S.filter.agents.includes('claude');
3430
- const hasPriced = () => selAgents().some(a => a.data !== 'activity');
3484
+ /* ---------- chrome ---------- */
3485
+ // Billed vs local needs an API key; every other page always shows.
3431
3486
  const hasCloudKey = () => Object.values(S.opts?.cloud || {}).some(Boolean);
3432
- const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
3433
- : scope === 'cloud' ? hasCloudKey() : hasPriced());
3434
- // "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
3435
- const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
3487
+ const navAllowed = scope => scope !== 'cloud' || hasCloudKey();
3436
3488
  // Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
3437
3489
  // or a shared dashboard always says which account the numbers belong to.
3438
3490
  function whoBlock() {
@@ -3450,22 +3502,25 @@ function whoBlock() {
3450
3502
  el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
3451
3503
  }
3452
3504
 
3453
- function applyAgentChrome() {
3454
- const a = selAgents();
3455
- const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
3505
+ function applyChrome() {
3506
+ const label = 'Claude';
3456
3507
  whoBlock();
3457
3508
  // Stamp the version onto the logo. Two copies of this app can be installed at
3458
3509
  // once (npm global, a checkout) and the browser cannot tell them apart.
3510
+ const from = S.opts?.served_from;
3511
+ const verText = S.opts?.version ? 'v' + S.opts.version + (from ? (from.kind === 'source'
3512
+ ? ` · source${from.commit ? ' ' + from.commit : ''}` : ' · npm') : '') : '';
3513
+ const verTitle = 'Version serving this page' + (from?.path ? ` (${from.path})` : '');
3459
3514
  const ver = $('#ver');
3460
- if (ver && S.opts?.version) {
3461
- ver.textContent = 'v' + S.opts.version;
3462
- ver.title = 'Version serving this page';
3515
+ if (ver && verText) {
3516
+ ver.textContent = verText;
3517
+ ver.title = verTitle;
3463
3518
  }
3464
3519
  const mark = $('.brand .mark');
3465
3520
  if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps` +
3466
- (S.opts?.version ? `<span class="ver" id="ver" title="Version serving this page">v${esc(S.opts.version)}</span>` : '');
3521
+ (verText ? `<span class="ver" id="ver" title="${esc(verTitle)}">${esc(verText)}</span>` : '');
3467
3522
  const sub = $('.brand .sub');
3468
- if (sub) sub.textContent = a.length > 1 ? a.map(x => x.name).join(' + ') : 'Command Center';
3523
+ if (sub) sub.textContent = 'Command Center';
3469
3524
  document.title = `${label} FinOps Command Center`;
3470
3525
  const scopes = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , , sc]) => [id, sc])));
3471
3526
  document.querySelectorAll('.nav a').forEach(el => el.style.display = navAllowed(scopes[el.dataset.view]) ? '' : 'none');
@@ -3475,7 +3530,7 @@ function applyAgentChrome() {
3475
3530
  while (el && !el.classList.contains('group')) { if (el.style.display !== 'none') any = true; el = el.nextElementSibling; }
3476
3531
  g.style.display = any ? '' : 'none';
3477
3532
  });
3478
- // leaving a page the new selection can't show: fall back to the overview
3533
+ // Billed vs local without a key: fall back to the overview
3479
3534
  if (scopes[S.view] && !navAllowed(scopes[S.view])) S.view = 'overview';
3480
3535
  }
3481
3536
 
@@ -3483,7 +3538,63 @@ function applyAgentChrome() {
3483
3538
  const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , l]) => [id, l])));
3484
3539
  TITLES.search = 'Global search';
3485
3540
 
3486
- function go(view) { S.view = view; closeDrawer(); render(); }
3541
+ function go(view) {
3542
+ const changed = view !== S.view;
3543
+ S.view = view; S.drawerRoute = null; closeDrawer();
3544
+ syncURL(changed); // a new page is a history entry; a new search on the same page is not
3545
+ render();
3546
+ }
3547
+
3548
+ /* Each page has its own URL: /<view>, plus ?session= / ?prompt= / ?add_model= for an open
3549
+ drawer, ?recent=1 for the Recent panel and ?q= for a search. Refresh keeps you there,
3550
+ Back/Forward walk through it. ?view=<name> still works for old links. */
3551
+ const HOME_VIEW = S.view;
3552
+ const DRAWER_KEYS = ['session', 'prompt', 'add_model'];
3553
+ let ROUTE_APPLYING = false;
3554
+ function routeURL() {
3555
+ const q = new URLSearchParams();
3556
+ if (S.view === 'search' && S.searchTerm) q.set('q', S.searchTerm);
3557
+ if (S.drawerRoute) q.set(S.drawerRoute.k, S.drawerRoute.id);
3558
+ if (RECENT.open) q.set('recent', '1');
3559
+ const s = q.toString();
3560
+ return '/' + S.view + (s ? '?' + s : '');
3561
+ }
3562
+ function syncURL(push) {
3563
+ if (ROUTE_APPLYING) return;
3564
+ const url = routeURL();
3565
+ if (url !== location.pathname + location.search) history[push ? 'pushState' : 'replaceState'](null, '', url);
3566
+ }
3567
+ function readRoute() {
3568
+ const p = new URLSearchParams(location.search);
3569
+ let view = decodeURIComponent(location.pathname.replace(/^\/+|\/+$/g, '')) || p.get('view') || '';
3570
+ // The repriced Model switch view was removed; its links now open the measured comparison.
3571
+ if (view === 'modelswitch') view = 'compare';
3572
+ const known = NAV.some(([, items]) => items.some(([id]) => id === view)) || (view === 'search' && p.get('q'));
3573
+ const k = DRAWER_KEYS.find(k => p.get(k));
3574
+ return {view: known ? view : null, q: p.get('q') || '', drawer: k ? {k, id: p.get(k)} : null,
3575
+ recent: p.get('recent') === '1'};
3576
+ }
3577
+ function openDrawerRoute(d) {
3578
+ if (d.k === 'session') openSession(d.id);
3579
+ else if (d.k === 'prompt') openPrompt(d.id);
3580
+ else if (d.k === 'add_model') addFreeModel(d.id);
3581
+ }
3582
+ // Back/Forward: show what the URL says, without writing new history.
3583
+ addEventListener('popstate', async () => {
3584
+ const r = readRoute(), v = r.view || HOME_VIEW, have = S.drawerRoute;
3585
+ const viewChanged = v !== S.view || (v === 'search' && r.q !== S.searchTerm);
3586
+ const reopen = r.drawer && (viewChanged || !have || have.k !== r.drawer.k || have.id !== r.drawer.id);
3587
+ ROUTE_APPLYING = true;
3588
+ try {
3589
+ if (viewChanged || reopen || !r.drawer) closeDrawer();
3590
+ if (r.recent !== RECENT.open) toggleRecent(r.recent);
3591
+ S.view = v;
3592
+ if (v === 'search') S.searchTerm = r.q;
3593
+ S.drawerRoute = r.drawer; // keep it in the URL while the page re-renders
3594
+ } finally { ROUTE_APPLYING = false; }
3595
+ if (viewChanged) await render();
3596
+ if (reopen) openDrawerRoute(r.drawer);
3597
+ });
3487
3598
 
3488
3599
  async function render() {
3489
3600
  C.hideTip(); // a tooltip whose chart is about to be replaced must not linger
@@ -3494,7 +3605,8 @@ async function render() {
3494
3605
  page.className = 'page';
3495
3606
  page.id = 'page';
3496
3607
  stale.replaceWith(page);
3497
- applyAgentChrome();
3608
+ applyChrome();
3609
+ syncURL(false);
3498
3610
  document.querySelectorAll('.nav a').forEach(a =>
3499
3611
  a.classList.toggle('on', a.dataset.view === S.view));
3500
3612
  $('#ttl').textContent = TITLES[S.view] || S.view;
@@ -3526,26 +3638,45 @@ async function render() {
3526
3638
  Past sessions and their prompts from any page, without leaving it. Resume and
3527
3639
  stop act on sessions, so the list is grouped by session; running ones sit on
3528
3640
  top. Close and Force kill stay on Running sessions, one click away from here. */
3529
- const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
3530
- // Recent means all time: keep agent/project filters, drop the date range and thresholds.
3641
+ // The header sort button orders past sessions: last used on top, or (oldest) at the bottom.
3642
+ // Running sessions always stay on top.
3643
+ // A session's prompts list newest first; its sort button flips that session to oldest first.
3644
+ const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set(), oldest: false, oldest_ids: new Set()};
3645
+ const oldestFirst = sid => RECENT.oldest_ids.has(sid);
3646
+ // Recent means all time: keep project filters, drop the date range and thresholds.
3531
3647
  const recentQs = () => { const p = new URLSearchParams(qs());
3532
3648
  ['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
3533
3649
  const fmtWhen = iso => iso ? `${ago(iso)} · ${esc(iso.slice(0, 16).replace('T', ' '))}` : '';
3534
3650
 
3535
3651
  function toggleRecent(force) {
3652
+ const was = RECENT.open;
3536
3653
  RECENT.open = force ?? !RECENT.open;
3654
+ if (was !== RECENT.open) syncURL(true);
3537
3655
  document.querySelectorAll('.recent,.recent-scrim').forEach(e => e.remove());
3538
3656
  $('#recent-btn')?.classList.toggle('on', RECENT.open);
3539
3657
  if (!RECENT.open) return;
3540
3658
  const scrim = h('<div class="recent-scrim"></div>');
3541
3659
  const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
3542
- <h2>Recent</h2><span class="note">all time · agent &amp; project filters apply</span>
3543
- <span class="spacer"></span><button class="iconbtn" data-x>Close ${I('x')}</button></header>
3660
+ <h2>Recent</h2><span class="note">all time · project filters apply</span>
3661
+ <span class="spacer"></span>
3662
+ <button class="iconbtn sq" data-sort-all aria-label="Session order"></button>
3663
+ <button class="iconbtn" data-x>Close ${I('x')}</button></header>
3544
3664
  <div class="recent-search"><span class="mag">${I('search')}</span>
3545
3665
  <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
3546
3666
  <div class="content"><div class="loading">Loading…</div></div></aside>`);
3547
3667
  scrim.onclick = () => toggleRecent(false);
3548
3668
  p.querySelector('[data-x]').onclick = () => toggleRecent(false);
3669
+ const sortAll = p.querySelector('[data-sort-all]');
3670
+ const sortAllLabel = () => {
3671
+ sortAll.innerHTML = I(RECENT.oldest ? 'sortAsc' : 'sortDesc');
3672
+ sortAll.title = RECENT.oldest ? 'Sessions: last used at the bottom. Click to put it on top'
3673
+ : 'Sessions: last used on top. Click to put it at the bottom';
3674
+ };
3675
+ sortAllLabel();
3676
+ sortAll.onclick = () => {
3677
+ RECENT.oldest = !RECENT.oldest; sortAllLabel();
3678
+ RECENT.offset = 0; renderRecent();
3679
+ };
3549
3680
  let t;
3550
3681
  p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
3551
3682
  t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
@@ -3559,15 +3690,17 @@ async function renderRecent() {
3559
3690
  if (!box) return;
3560
3691
  const more = RECENT.offset > 0;
3561
3692
  const [live, res] = await Promise.all([
3562
- more || RECENT.q ? null : fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(',')))
3693
+ more || RECENT.q ? null : fetch('/api/live')
3563
3694
  .then(r => r.json()).catch(() => ({sessions: []})),
3564
3695
  RECENT.q
3565
3696
  ? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
3566
- : fetch(`/api/sessions?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
3697
+ : fetch(`/api/sessions?${recentQs()}&order=${RECENT.oldest ? 'last_active_asc' : 'last_active'}&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
3567
3698
  if (!$('.recent')) return; // closed while loading
3568
3699
  const rows = res.rows || [];
3569
3700
  const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
3570
- if (live) RECENT.live = live.sessions || [];
3701
+ // Working sessions first, then the one written to most recently.
3702
+ if (live) RECENT.live = (live.sessions || []).slice().sort((a, b) =>
3703
+ (b.status === 'busy') - (a.status === 'busy') || (a.last_write_s ?? 1e12) - (b.last_write_s ?? 1e12));
3571
3704
  const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
3572
3705
  const shown = RECENT.offset + rows.length;
3573
3706
  const moreBtn = shown < (res.total || 0)
@@ -3590,7 +3723,7 @@ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running n
3590
3723
  ${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
3591
3724
  <div class="live-actions">
3592
3725
  ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">${I('copy')} Resume</button>` : ''}
3593
- ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
3726
+ ${x.signalable ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
3594
3727
  ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>${I('pause')} Stop</button>` : ''}
3595
3728
  ${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
3596
3729
  <span class="live-msg"></span></div></div>`).join('')}
@@ -3602,14 +3735,19 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
3602
3735
  <span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
3603
3736
  <span class="note">${fmtUSD(r.cost)}</span></div>
3604
3737
  <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
3605
- <span>${fmtWhen(r.started_at)}</span></div>
3738
+ <span title="Last prompt">${fmtWhen(r.last_ts || r.started_at)}</span></div>
3606
3739
  <div class="live-actions">
3607
3740
  ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
3608
3741
  <button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
3609
- <span class="spacer"></span><span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
3742
+ <span class="spacer"></span>${sortBtn(r.session_id)}<span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
3610
3743
  <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
3611
3744
  || '<div class="empty">No sessions for these filters</div>';
3612
3745
 
3746
+ const sortBtn = sid => { const old = oldestFirst(sid);
3747
+ return `<button class="act ghost recent-sort" data-sort title="${old
3748
+ ? 'Prompts: oldest first. Click for newest first' : 'Prompts: newest first. Click for oldest first'}"
3749
+ aria-label="Prompt order">${I(old ? 'sortAsc' : 'sortDesc')}</button>`; };
3750
+
3613
3751
  const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
3614
3752
  <span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
3615
3753
  <span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
@@ -3625,7 +3763,8 @@ async function loadSessionPrompts(host, sid) {
3625
3763
  host.innerHTML = '<div class="note">Loading…</div>';
3626
3764
  try {
3627
3765
  const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
3628
- const ps = s.prompts || [];
3766
+ const ps = s.prompts || []; // oldest first from the API
3767
+ if (!oldestFirst(sid)) ps.reverse();
3629
3768
  host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
3630
3769
  wireRecent(host);
3631
3770
  } catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
@@ -3644,6 +3783,15 @@ function wireRecent(root) {
3644
3783
  root.querySelectorAll('.item[data-sess]').forEach(it => {
3645
3784
  const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
3646
3785
  if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
3786
+ const sb = it.querySelector('[data-sort]');
3787
+ sb.onclick = e => {
3788
+ e.stopPropagation();
3789
+ RECENT.oldest_ids.has(sid) ? RECENT.oldest_ids.delete(sid) : RECENT.oldest_ids.add(sid);
3790
+ const t = h(sortBtn(sid));
3791
+ sb.innerHTML = t.innerHTML; sb.title = t.title;
3792
+ if (host.hidden) it.click(); // sorting a closed list opens it
3793
+ else loadSessionPrompts(host, sid);
3794
+ };
3647
3795
  it.onclick = e => {
3648
3796
  if (e.target.closest('button, a, .recent-prompts')) return;
3649
3797
  host.hidden = !host.hidden;
@@ -3660,7 +3808,7 @@ function wireRecent(root) {
3660
3808
  if (!armed(b)) return; // two clicks, like Running sessions
3661
3809
  b.disabled = true;
3662
3810
  try {
3663
- const r = await sessionAction(x.pid, b.dataset.la, {agent: x.agent});
3811
+ const r = await sessionAction(x.pid, b.dataset.la);
3664
3812
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
3665
3813
  msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
3666
3814
  msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
@@ -3676,93 +3824,20 @@ function wireRecent(root) {
3676
3824
  if (saved) document.documentElement.setAttribute('data-theme', saved);
3677
3825
  shell();
3678
3826
  S.opts = await fetch('/api/options').then(r => r.json());
3679
- S.filter.agents = defaultAgents();
3680
3827
  applyRange('30d');
3681
- // ?view=<name> opens straight to one screen, so a link (or a screenshot run)
3682
- // can point at a specific report rather than always landing on the overview.
3683
- let want = new URLSearchParams(location.search).get('view');
3684
- // The repriced Model switch view was removed; its links now open the measured comparison.
3685
- if (want === 'modelswitch') want = 'compare';
3686
- if (want && NAV.some(([, items]) => items.some(([id]) => id === want))) S.view = want;
3828
+ // The URL picks the screen (and any open drawer), so a refresh or a link lands where it points.
3829
+ const route = readRoute();
3830
+ if (route.view) S.view = route.view;
3831
+ if (route.view === 'search') S.searchTerm = route.q;
3832
+ S.drawerRoute = route.drawer; // kept in the URL until the drawer opens below
3833
+ RECENT.open = route.recent;
3687
3834
  await render();
3688
- if (!want) maybeFirstTour(); // arriving on a deep link is not a first visit
3835
+ if (route.recent) toggleRecent(true);
3836
+ if (route.drawer) openDrawerRoute(route.drawer);
3837
+ if (!route.view) maybeFirstTour(); // arriving on a deep link is not a first visit
3689
3838
  let t; addEventListener('resize', () => { clearTimeout(t); t = setTimeout(render, 220); });
3690
3839
  })();
3691
3840
 
3692
- /* ============================ agents ============================ */
3693
- // Last choice if still valid, else the agent with the most usage (Claude on most machines).
3694
- function defaultAgents() {
3695
- const ag = S.opts?.agents || [];
3696
- let saved = [];
3697
- try { saved = JSON.parse(localStorage.getItem('finops-agents') || '[]'); } catch (_) {}
3698
- saved = saved.filter(id => ag.some(a => a.id === id));
3699
- if (saved.length) return saved;
3700
- const top = [...ag].sort((a, b) => b.requests - a.requests)[0];
3701
- return top ? [top.id] : [];
3702
- }
3703
-
3704
- VIEWS.agents = async (page) => {
3705
- const d = await api('by_agent');
3706
- const all = S.opts.agents || [];
3707
- const colorOf = id => seriesVar(Math.max(all.findIndex(a => a.id === id), 0));
3708
- const dataBadge = k => ({full: '<span class="badge">Full data</span>',
3709
- tokens: '<span class="badge">Tokens + model</span>',
3710
- activity: '<span class="badge rec">Activity only</span>'}[k] || '');
3711
- // pivot daily rows into one row per day with a column per agent
3712
- const byDay = new Map();
3713
- for (const r of d.daily) {
3714
- const row = byDay.get(r.day) || {day: r.day};
3715
- row[r.agent] = S.metric === 'cost' ? r.cost : r.tokens;
3716
- byDay.set(r.day, row);
3717
- }
3718
- const shown = d.agents.map(a => a.agent);
3719
- page.innerHTML = `
3720
- <div class="note">Showing: <b>${esc(shown.map(id => all.find(a => a.id === id)?.name || id).join(' + ') || 'nothing in range')}</b>.
3721
- Click an agent chip above to see only it; <b>Cmd/Ctrl-click</b> to add more, or <b>All</b> for every agent together.
3722
- Every other page follows the same selection.</div>
3723
- <div class="grid g4">${d.agents.map(a => kpi(a.name,
3724
- // an agent with no price data leads with what it does have, never "0 tok"
3725
- a.cost ? fmtUSD(a.cost) : a.tokens ? fmtNum(a.tokens) + ' tokens' : fmtInt(a.requests) + ' requests',
3726
- `${fmtInt(a.sessions)} sessions · ${fmtInt(a.prompts)} prompts · ${fmtInt(a.active_days)} active days`,
3727
- {badge: a.cost ? BADGE.estimated : ''})).join('')}</div>
3728
- ${card('Side by side', `<div id="ag-tbl"></div>`, {flush: 1, badge: BADGE.estimated,
3729
- hint: 'Costs are estimated at each provider\'s API list price'})}
3730
- ${card(`Daily ${S.metric === 'cost' ? 'estimated cost' : 'tokens'} by agent`, `<div class="chart" id="ag-trend"></div><div id="ag-leg"></div>`,
3731
- {actions: `<button class="chip ${S.metric === 'cost' ? 'on' : ''}" data-m="cost">Cost</button>
3732
- <button class="chip ${S.metric !== 'cost' ? 'on' : ''}" data-m="tokens">Tokens</button>`})}
3733
- ${card('What each agent records on this machine', `<div class="stack">${all.map(a => `
3734
- <div class="item"><div class="hd">${esc(a.name)} ${dataBadge(a.data)}<span class="spacer"></span>
3735
- <span class="note">${a.requests ? `${esc(a.first)} → ${esc(a.last)} · ${fmtInt(a.sessions)} sessions` : 'installed, no usage recorded'}</span></div>
3736
- <div class="dt">${esc(a.note)}</div></div>`).join('')}</div>`)}`;
3737
- $('#ag-tbl', page).innerHTML = table([
3738
- {h: 'Agent', f: r => `<b>${esc(r.name)}</b> ${dataBadge(r.data)}`},
3739
- {h: 'Est. cost', num: 1, f: r => r.data === 'activity' ? '<span class="na">not priced</span>' : fmtUSD(r.cost)},
3740
- {h: 'Tokens', num: 1, f: r => r.tokens ? fmtNum(r.tokens) : '<span class="na">not recorded</span>'},
3741
- {h: 'Output', num: 1, f: r => r.output_tokens ? fmtNum(r.output_tokens) : '—'},
3742
- {h: 'Cache reads', num: 1, f: r => r.cache_read_tokens ? fmtNum(r.cache_read_tokens) : '—'},
3743
- {h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
3744
- {h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
3745
- {h: 'Tool calls', num: 1, f: r => fmtInt(r.tool_calls)},
3746
- {h: 'Cost / prompt', num: 1, f: r => r.cost && r.prompts ? fmtUSD(r.cost / r.prompts) : '—'},
3747
- {h: 'Models', trunc: 1, title: r => r.models, f: r => esc((r.models || '').split(',').map(modelName).join(', '))},
3748
- ], d.agents);
3749
- const series = shown.map(id => ({key: id, label: all.find(a => a.id === id)?.name || id, color: colorOf(id)}));
3750
- C.timeSeries($('#ag-trend', page), {rows: [...byDay.values()], x: 'day', series, type: 'bar',
3751
- fmt: S.metric === 'cost' ? fmtUSD : fmtNum, height: 230, xLabel: shortDay});
3752
- C.legend($('#ag-leg', page), series.map(s => ({label: s.label, color: s.color})));
3753
- page.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { S.metric = b.dataset.m; render(); });
3754
- addChart(page, 'Share by agent', el => {
3755
- el.innerHTML = '<div class="grid g2"><div><div class="note">Estimated cost</div><div id="ag-d1"></div></div>' +
3756
- '<div><div class="note">Tokens</div><div id="ag-d2"></div></div></div>';
3757
- C.donut($('#ag-d1', el), {rows: d.agents.filter(a => a.cost > 0), label: a => a.name, value: a => a.cost,
3758
- color: a => colorOf(a.agent), size: 170, centerValue: fmtUSD(d.agents.reduce((x, a) => x + a.cost, 0)), centerLabel: 'estimated'});
3759
- C.donut($('#ag-d2', el), {rows: d.agents.filter(a => a.tokens > 0), label: a => a.name, value: a => a.tokens, fmt: fmtNum,
3760
- color: a => colorOf(a.agent), size: 170, centerValue: fmtNum(d.agents.reduce((x, a) => x + (a.tokens || 0), 0)), centerLabel: 'tokens'});
3761
- const lg = document.createElement('div'); el.appendChild(lg);
3762
- C.legend(lg, d.agents.map(a => ({label: `${a.name} · ${a.cost ? fmtUSD(a.cost) + ' · ' : ''}${fmtNum(a.tokens)} tok`, color: colorOf(a.agent)})));
3763
- }, {badge: BADGE.estimated});
3764
- };
3765
-
3766
3841
  /* ============================ machine actions ============================ */
3767
3842
  async function doAction(path, body = {}) {
3768
3843
  const r = await fetch(`/api/do/${path}`, {method: 'POST',
@@ -3796,7 +3871,7 @@ async function syncLabel() {
3796
3871
  const b = $('#sync'); if (!b) return;
3797
3872
  const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
3798
3873
  if (d.job && d.job.state === 'running') return runSync();
3799
- b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's transcripts from disk into the warehouse`;
3874
+ b.title = `Last synced ${ago(d.built_at)}. Re-read Claude Code's transcripts from disk into the warehouse`;
3800
3875
  b.classList.remove('busy');
3801
3876
  b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
3802
3877
  }
@@ -3865,6 +3940,7 @@ async function testFreeModel(id, host, btn) {
3865
3940
  // Show exactly what will happen, collect anything needed, run only after the user confirms.
3866
3941
  async function addFreeModel(id) {
3867
3942
  const d = drawer('Add free model', '<div class="loading">Checking prerequisites…</div>');
3943
+ setDrawerRoute('add_model', id);
3868
3944
  const c = d.querySelector('.content');
3869
3945
  const plan = await fetch(`/api/free_models/${id}/plan`).then(r => r.json());
3870
3946
  if (plan.blocked) { c.innerHTML = `<div class="item sev-high"><div class="dt" style="white-space:pre-wrap">${esc(plan.blocked)}</div></div>`; return; }
@@ -3918,7 +3994,7 @@ VIEWS.compare = async (page) => {
3918
3994
  ? (r.requests ? `${fmtInt(r.requests)} req · ${fmtUSD(r.cost)}` : '<span class="na">not used</span>')
3919
3995
  : !r.fits ? `${I('xCircle')} needs ${r.min_ram_gb} GB RAM` : r.installed ? `${I('checkCircle')} <code>${esc(r.command)}</code>` : `<a data-go="freemodels">${I('plus')} Add</a>`;
3920
3996
  page.innerHTML = `
3921
- ${card(hasFree ? 'Claude vs free models' : `${esc(agentWord())} models side by side`, `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
3997
+ ${card(hasFree ? 'Claude vs free models' : 'Claude models side by side', `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
3922
3998
  hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
3923
3999
  ${!hasFree ? '' : `<div class="grid g3">
3924
4000
  ${card('Use Claude for', `<div class="dt">Multi-file changes, debugging, anything agentic or long-running.
@@ -4252,16 +4328,13 @@ VIEWS.cloud = async (page) => {
4252
4328
  `${fmtNum(t.local_claude_tokens)} tokens from local transcripts`, {badge: BADGE.estimated})}
4253
4329
  ${kpi('Gap', t.billed_claude_cost ? fmtUSD(gap) : null,
4254
4330
  'Other machines, other members, or work off this machine')}
4255
- ${kpi('Cursor team spend', t.cursor_spend == null ? null : fmtUSD(t.cursor_spend),
4256
- `${fmtInt(t.cursor_members)} members`, {badge: BADGE.actual})}
4257
4331
  </div>
4258
4332
  ${card('Set up the APIs', `<div class="stack">${setup}</div>`,
4259
4333
  {hint: 'Stored keys stay on this machine (0600) and are never shown again'})}
4260
4334
  ${card('Billed vs local, per day', `<div id="cl-day"></div>`, {flush: 1, badge: BADGE.actual,
4261
- hint: 'Claude Code: what the vendor billed against what this machine recorded'})}
4335
+ hint: 'Claude Code: what Anthropic billed against what this machine recorded'})}
4262
4336
  ${card('Claude Code users (org-wide)', `<div id="cl-users"></div>`, {flush: 1, badge: BADGE.actual,
4263
4337
  hint: 'Per user per day, aggregated over the range — includes Pro/Max subscription users'})}
4264
- ${card('Cursor members', `<div id="cl-cur"></div>`, {flush: 1, badge: BADGE.actual})}
4265
4338
  ${card('Billed API cost by line item', `<div id="cl-api"></div>`, {flush: 1, badge: BADGE.actual,
4266
4339
  hint: 'From the Anthropic cost report: API spend only, not subscription plans'})}`;
4267
4340
  page.querySelectorAll('[data-go-settings]').forEach(a => a.onclick = e => { e.preventDefault(); go('settings'); });
@@ -4284,13 +4357,6 @@ VIEWS.cloud = async (page) => {
4284
4357
  {h: 'PRs', num: 1, f: r => fmtInt(r.prs)},
4285
4358
  {h: 'Where', trunc: 1, f: r => esc(r.terminals.join(', ') || '—')},
4286
4359
  ], d.users);
4287
- $('#cl-cur', page).innerHTML = table([
4288
- {h: 'Member', f: r => esc(r.member)},
4289
- {h: 'Spend', num: 1, f: r => r.spend_usd == null ? '—' : fmtUSD(r.spend_usd)},
4290
- {h: 'Active days', num: 1, f: r => fmtInt(r.days)},
4291
- {h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
4292
- {h: 'Lines +/-', num: 1, f: r => `${fmtInt(r.lines_added)} / ${fmtInt(r.lines_removed)}`},
4293
- ], d.cursor_members);
4294
4360
  $('#cl-api', page).innerHTML = table([
4295
4361
  {h: 'Line item', f: r => esc(r.description)},
4296
4362
  {h: 'Billed $', num: 1, f: r => fmtUSD(r.cost_usd)},
@@ -4316,21 +4382,18 @@ VIEWS.cloud = async (page) => {
4316
4382
  // Targets: a CSS selector, 'kpis' (the first KPI row), or 'cardN' (Nth card on the page).
4317
4383
  const TOUR_DONE = 'finops-tour-done';
4318
4384
  const TOUR_WELCOME = [
4319
- {el: '.brand', t: 'Welcome to FinOps', see: 'One dashboard for what your coding agents (Claude Code, Codex, Gemini CLI, Cursor) spend in tokens and money.',
4385
+ {el: '.brand', t: 'Welcome to FinOps', see: 'One dashboard for what Claude Code spends in tokens and money.',
4320
4386
  get: 'Where the tokens go, why, and what to change to spend less. Everything is read from files on this machine.',
4321
4387
  act: 'Use Next and Previous to move through the tour, or Skip to close it.'},
4322
4388
  {el: '.nav', t: 'Dashboards', see: 'Every dashboard, grouped: Command center, Usage, Drill-down, Optimize, Plan.',
4323
4389
  get: 'Start with <b>Why so many tokens?</b> or <b>What should I do?</b> for a ranked to-do list.',
4324
4390
  act: 'Click any item to open that dashboard. A number next to an item means something needs attention.'},
4325
- {el: '[data-agent]', t: 'Pick the agent', see: 'Agent chips: one agent, several, or All.',
4326
- get: 'Every dashboard is filtered to the agents you pick. Dashboards that need data an agent doesn\'t record are hidden.',
4327
- act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
4328
4391
  {el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
4329
4392
  get: 'Narrow any dashboard to a period, a model or a project.',
4330
4393
  act: `Try <b>7 days</b>, then open <b>Project ${I('chevDown')}</b> to focus on one repo.`},
4331
4394
  {el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
4332
4395
  get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
4333
- {el: '#sync', t: 'Keep it current', see: 'Sync re-reads every agent\'s local data.',
4396
+ {el: '#sync', t: 'Keep it current', see: 'Sync re-reads Claude Code\'s local transcripts.',
4334
4397
  get: 'Fresh numbers after you\'ve been working.', act: `Click <b>${I('sync')} Sync</b> whenever the numbers look stale.`},
4335
4398
  {el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
4336
4399
  get: 'A short explanation of each section: what it shows, what you get, what you can do.',
@@ -4357,15 +4420,10 @@ const TOURS = {
4357
4420
  {el: 'card:What is going well', t: 'Strengths & gaps', see: 'What your habits already do well, and the items that need attention.', get: 'Confirmation of what to keep doing, and where you lose money.', act: 'Work down the <b>Needs attention</b> list.'},
4358
4421
  {el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation, ranked by spend involved, each with its reasoning.', get: 'A prioritised to-do list built from your own usage.', act: 'Copy a recommendation\'s prompt straight into your agent.'},
4359
4422
  {el: 'card:Anomalies to inspect', t: 'Anomalies', see: 'Days and sessions that spent far more than your normal.', get: 'Surprises caught before they repeat.', act: 'Click one to see what happened that day.'}],
4360
- agents: [
4361
- {el: 'kpis', t: 'Agent totals', see: 'One tile per agent with its usage for the current filters.', get: 'How spend splits across Claude Code, Codex, Gemini CLI and Cursor.', act: 'Use the agent chips in the header to focus on one.'},
4362
- {el: 'card:Side by side', t: 'Side by side', see: 'Every agent in one table: cost, tokens, requests, sessions.', get: 'A like-for-like comparison of what each agent costs you.', act: 'Compare tokens per request: a high number means large contexts.'},
4363
- {el: 'card:Daily', t: 'Daily by agent', see: 'Cost or tokens per day, split by agent.', get: 'When you switched agents and what that did to spend.', act: 'Use the metric toggle to swap cost for tokens.'},
4364
- {el: 'card:What each agent records', t: 'What each agent records', see: 'Which fields each agent writes to disk on this machine.', get: 'Why some dashboards are hidden for some agents: the data simply isn\'t recorded.', act: 'Check here first when a number reads "not recorded".'}],
4365
4423
  live: [
4366
4424
  {el: 'kpis', t: 'Running now', see: 'Sessions running right now, which are working (spending) at this moment, the largest live context and what live sessions have spent.', get: 'What is costing you money this second.', act: 'Only <b>working</b> sessions consume tokens; idle ones cost again on your next message.'},
4367
- {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
4368
- {el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, <b>Hand over</b> a Claude session to a fresh one, see its <b>Prompts</b> or <b>Rename</b> it.'}],
4425
+ {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Compact, Close and Force kill do.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
4426
+ {el: '.item[data-i]', t: 'A session', see: 'Project, model, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, <b>Hand over</b> it to a fresh session, see its <b>Prompts</b> or <b>Rename</b> it.'}],
4369
4427
  scorecard: [
4370
4428
  {el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
4371
4429
  {el: 'card:What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
@@ -4426,9 +4484,9 @@ const TOURS = {
4426
4484
  {el: 'card:Add to', t: 'Memory suggestions', see: 'Facts your prompts repeat, ready to move into your memory file.', get: 'Shorter prompts and less re-explaining.', act: 'Copy a suggestion into the project memory file.'},
4427
4485
  {el: 'card:Why consumption is high', t: 'The drivers', see: 'The main drivers of your token use, each with its share.', get: 'A plain explanation of where tokens go.', act: 'Read the top driver first.'},
4428
4486
  {el: 'card:What to change', t: 'What to change', see: 'Each finding with a ready-made prompt that fixes it.', get: 'A fix your agent can apply for you.', act: 'Click <b>Copy</b> and paste it into your agent.'},
4429
- {el: 'card:Projects:', t: 'Project config health', see: 'Per project: whether it has memory, an agents file, hooks and the rest.', get: 'The config gaps that make every session start from zero.', act: 'Fix the projects flagged red.'},
4487
+ {el: 'card:Projects:', t: 'Project config health', see: 'Per project: whether it has memory, a CLAUDE.md, hooks and the rest.', get: 'The config gaps that make every session start from zero.', act: 'Fix the projects flagged red.'},
4430
4488
  {el: '#dx-harness', t: 'Does it need a harness?', see: 'Per project, whether the work you do there would be better served by a harness: hooks, subagents or a scripted workflow.', get: 'A read on when a repo has outgrown ad-hoc prompting.', act: 'Start with the project that repeats the same multi-step task most often.'},
4431
- {el: 'card:Global config', t: 'Global config', see: 'Your machine-wide agent configuration and what\'s missing from it.', get: 'Settings that pay off across every project at once.', act: 'Apply the global fixes before the per-project ones.'},
4489
+ {el: 'card:Global config', t: 'Global config', see: 'Your machine-wide Claude Code configuration and what\'s missing from it.', get: 'Settings that pay off across every project at once.', act: 'Apply the global fixes before the per-project ones.'},
4432
4490
  {el: '#dx-issues', t: 'Per-project fixes', see: 'One card per project that has open issues, each with the exact fix for that repo.', get: 'The work split by repo, so you can fix one and stop.', act: 'Fix your most expensive repo first; <b>Project cost</b> on the overview says which that is.'}],
4433
4491
  attribution: [
4434
4492
  {el: 'kpis', t: 'Who used the tokens', see: 'Tokens split by session, subagent, skill, MCP server and connector.', get: 'Which add-ons quietly cost the most context.', act: 'Compare main-agent tokens with subagent tokens.'},
@@ -4492,14 +4550,13 @@ const TOURS = {
4492
4550
  {el: 'card:Set your limits', t: 'Suggestions and shorthand', see: 'Chips under each amount suggest values from your own last 30 days and session sizes; amounts accept 20M, 500k or $3,000.', get: 'A sensible budget in one click, and a clear message when a value will not work.', act: 'Click a suggestion, adjust it, and Save.'},
4493
4551
  {el: 'card:Set your limits', t: 'Session guard', see: 'A token budget for each Claude Code session, warn percentages, what happens after you approve, per-project overrides, and the Live warnings box (optional).', get: 'A warning while a session grows, and a pause for your approval once it reaches its budget.', act: 'Set a budget, save, then, if you want live warnings, <b>Install</b> in the Live warnings box. It is optional. It applies to new sessions.'}],
4494
4552
  settings: [
4495
- {el: 'card:API keys', t: 'API keys', see: 'Optional keys for the Anthropic Admin API and Cursor.', get: 'The Billed vs local page, without the terminal.', act: 'Paste a key and press Save key. It is never shown again, only its last four characters.'}],
4553
+ {el: 'card:API keys', t: 'API keys', see: 'An optional key for the Anthropic Admin API.', get: 'The Billed vs local page, without the terminal.', act: 'Paste a key and press Save key. It is never shown again, only its last four characters.'}],
4496
4554
  cloud: [
4497
- {el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: `Click <b>${I('cloudDown')} Refresh from APIs</b> to fetch — this is the only page that goes online.`},
4498
- {el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Which provider keys were found, and how to get each one.', get: 'Org-wide Claude Code usage per user, and Cursor team spend.', act: 'Add a key on the Settings page, or run claude-finops --set-key.'},
4555
+ {el: 'kpis', t: 'Billed vs local', see: 'What Anthropic billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: `Click <b>${I('cloudDown')} Refresh from APIs</b> to fetch — this is the only page that goes online.`},
4556
+ {el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Whether an Anthropic Admin key was found, and how to get one.', get: 'Org-wide Claude Code usage per user.', act: 'Add a key on the Settings page, or run claude-finops --set-key.'},
4499
4557
  {el: 'card:Billed vs local', t: 'The comparison', see: 'Billed totals against local totals for the same period.', get: 'Proof of how much of the bill this machine explains.', act: 'A large gap means most spend happens elsewhere: check the user tables.'},
4500
4558
  {el: 'card:Claude Code users', t: 'Users org-wide', see: 'Each Claude Code user in the organisation and their usage.', get: 'Who drives the bill across the team.', act: 'Compare your own row with the team average.'},
4501
- {el: 'card:Cursor members', t: 'Cursor members', see: 'Cursor team members and their spend.', get: 'The same picture for Cursor seats.', act: 'Look for seats with no usage at all.'},
4502
- {el: 'card:Billed API cost by line item', t: 'Billed line items', see: 'The vendor\'s own line items for the period.', get: 'The invoice view, straight from the API.', act: 'Reconcile this with your finance numbers.'}],
4559
+ {el: 'card:Billed API cost by line item', t: 'Billed line items', see: 'Anthropic\'s own line items for the period.', get: 'The invoice view, straight from the API.', act: 'Reconcile this with your finance numbers.'}],
4503
4560
  search: [
4504
4561
  {el: 'card:Prompts', t: 'Matching prompts', see: 'Every prompt whose text matches your search, with what it cost.', get: 'One term, and everything you ever asked about it.', act: 'Click a prompt to open it with its requests and files.'},
4505
4562
  {el: 'card:Sessions', t: 'Matching sessions', see: 'Sessions whose title or prompts match, with cost and size.', get: 'The whole working session around a match, not just the one line.', act: 'Click a session to see how it grew.'},