claude-finops 0.13.0 → 0.14.0

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
@@ -148,6 +148,7 @@ const NAV = [
148
148
  ['attribution', 'users', 'Who used the tokens', 'priced'],
149
149
  ['waste', 'trash', 'Waste detection'],
150
150
  ['freemodels', 'gift', 'Free models', 'claude'],
151
+ ['jev', 'sparkles', 'Jev (fast decisions)'],
151
152
  ['compare', 'scale', 'Compare models', 'priced'],
152
153
  ['toolkit', 'wrench', 'Skills & MCP', 'claude'],
153
154
  ['recommendations', 'listChecks', 'Recommendations'],
@@ -159,6 +160,9 @@ const NAV = [
159
160
  ['cloud', 'cloud', 'Billed vs local', 'cloud'],
160
161
  ['exports', 'download', 'Export & data'],
161
162
  ]],
163
+ ['Setup', [
164
+ ['settings', 'cog', 'Settings'],
165
+ ]],
162
166
  ];
163
167
 
164
168
  // Toolbar icons: 24px line icons drawn in currentColor, so they follow the theme.
@@ -194,6 +198,7 @@ const IP = {
194
198
  copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>',
195
199
  handover: '<path d="m15 17 5-5-5-5"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/>',
196
200
  plus: '<path d="M12 5v14M5 12h14"/>',
201
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14"/><path d="M12 17h.01"/>',
197
202
  pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
198
203
  stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
199
204
  play: '<path d="M7 4v16l13-8z"/>',
@@ -463,7 +468,7 @@ function filterBar() {
463
468
  ${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
464
469
 
465
470
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
466
- b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
471
+ b.onclick = e => { if (b.dataset.range === 'custom') { e.stopPropagation(); return openCustom(b); }
467
472
  applyRange(b.dataset.range); bust(); render(); });
468
473
  $('#filters').querySelectorAll('[data-agent]').forEach(b => b.onclick = e => {
469
474
  const id = b.dataset.agent;
@@ -525,13 +530,15 @@ function openPop(btn, kind) {
525
530
  }
526
531
  }
527
532
  function openCustom(btn) {
528
- closePops();
529
- const pop = h(`<div class="pop"><div class="hd">From</div>
533
+ const open = btn.parentElement.querySelector('.pop');
534
+ closePops(); if (open) return;
535
+ const pop = h(`<div class="pop" style="left:auto;right:0"><div class="hd">From</div>
530
536
  <input type="date" id="cs" value="${S.filter.start || S.opts.date_range.first}">
531
537
  <div class="hd">To</div>
532
538
  <input type="date" id="ce" value="${S.filter.end || S.opts.date_range.last}">
533
539
  <button class="chip on" id="ca" style="width:100%;justify-content:center">Apply</button></div>`);
534
- btn.parentElement.insertBefore(pop, btn.nextSibling);
540
+ btn.parentElement.style.position = 'relative';
541
+ btn.parentElement.appendChild(pop);
535
542
  pop.onclick = e => e.stopPropagation();
536
543
  pop.querySelector('#ca').onclick = () => {
537
544
  S.filter.start = pop.querySelector('#cs').value;
@@ -1368,6 +1375,15 @@ VIEWS.projects = async (page) => {
1368
1375
  };
1369
1376
 
1370
1377
  /* ---------- sessions ---------- */
1378
+ // With a session token budget the dot follows it (whole-session tokens); without one,
1379
+ // it keeps flagging rows far above the loaded average.
1380
+ function sessFlag(r, avgTok) {
1381
+ if (!r.budget_tokens) return r.tokens > avgTok * 3 ? dot('red') + ' ' : '';
1382
+ const pct = 100 * (r.session_tokens || 0) / r.budget_tokens;
1383
+ if (pct < 75) return '';
1384
+ return `<span title="${fmtPct(pct)} of your ${fmtNum(r.budget_tokens)}-token session budget">${
1385
+ dot(pct >= 100 ? 'red' : 'yellow')}</span> `;
1386
+ }
1371
1387
  VIEWS.sessions = async (page) => {
1372
1388
  const order = S.sessOrder || 'cost';
1373
1389
  const sp = S.sessPage;
@@ -1389,7 +1405,9 @@ VIEWS.sessions = async (page) => {
1389
1405
  ['prompts', 'Most prompts'], ['recent', 'Most recent']].map(([k, l]) =>
1390
1406
  `<button class="chip ${order === k ? 'on' : ''}" data-so="${k}">${l}</button>`).join('')}
1391
1407
  <span class="spacer"></span>
1392
- <span class="note">rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive</span>
1408
+ <span class="note">${rows.some(r => r.budget_tokens)
1409
+ ? 'dots mark sessions at 75% (amber) and 100% (red) of your session token budget'
1410
+ : `rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive`}</span>
1393
1411
  </div><div id="st"></div>
1394
1412
  <div class="filters" style="margin-top:8px">
1395
1413
  <span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)}</span>
@@ -1416,7 +1434,7 @@ VIEWS.sessions = async (page) => {
1416
1434
  if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
1417
1435
  $('#st', page).innerHTML = table([
1418
1436
  {h: 'Session', trunc: 1, title: r => r.session_id,
1419
- f: r => `${r.tokens > avgTok * 3 ? dot('red') + ' ' : ''}${esc(stitle(r))}
1437
+ f: r => `${sessFlag(r, avgTok)}${esc(stitle(r))}
1420
1438
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1421
1439
  {h: '', f: r => rowActs(r, live)},
1422
1440
  {h: 'Project', f: r => esc(r.project)},
@@ -1995,6 +2013,71 @@ const usageBody = u => {
1995
2013
  <div class="note">${esc(u.note || '')}${u.cached ? ` · read ${dur(u.age_s)} ago` : ''}${u.stale
1996
2014
  ? ` · <b>last good read</b> (refresh failed: ${esc(u.error || '')})` : ''}</div>`;
1997
2015
  };
2016
+ /* ---------- one running session's own token limit ---------- */
2017
+ function limitBadge(sid) {
2018
+ const o = ((S.opts?.settings?.guard || {}).sessions || {})[sid];
2019
+ if (!o) return '';
2020
+ return o.off ? '<span class="pill" title="The session guard is off for this conversation">Guard off</span>'
2021
+ : `<span class="pill" data-limit-badge="${esc(sid)}" title="This conversation's own token limit">Limit ${esc(fmtNum(o.session_tokens))}</span>`;
2022
+ }
2023
+ // The Set limit panel: tokens used so far (as the guard counts them), the limit box with
2024
+ // suggestions from that number, and save / remove / turn off.
2025
+ async function drawLimit(box, x, i) {
2026
+ const sid = x.session_id;
2027
+ box.innerHTML = '<div class="note">Counting this conversation\'s tokens…</div>';
2028
+ let d;
2029
+ try {
2030
+ const r = await fetch(`/api/guard/session/${encodeURIComponent(sid)}`);
2031
+ d = await r.json();
2032
+ if (!r.ok) throw new Error(d.error || 'Could not read this session.');
2033
+ } catch (e) { box.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; return; }
2034
+ const own = d.limit, id = `lim-${i}`, used = d.tokens || 0;
2035
+ const now = own?.off ? 'The guard is off for this conversation.'
2036
+ : own ? `This conversation's limit: <b>${fmtNum(own.session_tokens)}</b> (${fmtPct(100 * used / own.session_tokens, 0)} used).`
2037
+ : d.applies ? `No limit of its own yet. It uses the per-session budget from Budgets: <b>${fmtNum(d.applies)}</b>.`
2038
+ : 'No limit yet.';
2039
+ box.innerHTML = `<div class="cfg">
2040
+ <div class="fld-hint">Used so far: <b>${fmtNum(used)}</b> tokens. This counts everything the conversation has used,
2041
+ including re-reading its context at every step, so it is much bigger than the context size above.</div>
2042
+ ${d.installed ? '' : `<div class="lb-state warn" style="margin-top:6px">Live warnings are not installed, so a limit
2043
+ here won't do anything yet. <button class="act" data-lim-setup>Set up live warnings</button></div>`}
2044
+ <div class="fld-hint" style="margin-top:6px">${now}</div>
2045
+ ${amountField({id, label: 'Token limit for this conversation', kind: 'tokens', value: own?.session_tokens ?? null,
2046
+ chips: tokChips([[used * 1.1, '+10%'], [used * 1.25, '+25%'], [used * 1.5, '+50%'], [used * 2, '2×']], 3)})}
2047
+ <div class="fld-hint">When this conversation reaches its limit, Claude Code asks you "continue?" before its next
2048
+ step. Your warn percentages from Budgets apply too. The limit is forgotten after 30 days.</div>
2049
+ <div class="live-actions">
2050
+ <button class="act" data-lim-save>Save limit</button>
2051
+ ${own ? '<button class="act ghost" data-lim-rm>Remove limit</button>' : ''}
2052
+ ${own?.off ? '' : '<button class="act ghost" data-lim-off>Turn the guard off for this conversation</button>'}
2053
+ <span class="live-msg" role="status"></span>
2054
+ </div></div>`;
2055
+ const inp = $('#' + id, box), msg = box.querySelector('.live-msg');
2056
+ box.querySelectorAll('[data-fill]').forEach(c => c.onclick = () => {
2057
+ inp.value = shortAmount(+c.dataset.v); inp.dispatchEvent(new Event('input')); });
2058
+ inp.addEventListener('input', () => clearErr(box, id));
2059
+ const setup = box.querySelector('[data-lim-setup]');
2060
+ if (setup) setup.onclick = () => go('budgets');
2061
+ const send = async (body, done) => {
2062
+ try {
2063
+ await doAction(`session_limit/${encodeURIComponent(sid)}`, body);
2064
+ S.opts = await fetch('/api/options').then(r => r.json());
2065
+ const hd = box.closest('.item')?.querySelector('.hd');
2066
+ hd?.querySelectorAll('.pill[title]').forEach(p => p.remove());
2067
+ hd?.querySelector('.spacer')?.insertAdjacentHTML('beforebegin', limitBadge(sid));
2068
+ await drawLimit(box, x, i);
2069
+ const m = box.querySelector('.live-msg'); if (m) { m.classList.add('ok'); m.textContent = done; }
2070
+ } catch (e) { msg.classList.add('err'); msg.textContent = e.message; }
2071
+ };
2072
+ box.querySelector('[data-lim-save]').onclick = () => {
2073
+ const e = amountError(inp) || (inp.value.trim() ? '' : 'Type a limit, or tap a suggestion.');
2074
+ if (e) return setErr(box, id, e);
2075
+ send({session_tokens: parseAmount(inp.value, 'tokens').value}, 'Saved. It applies from the next step.');
2076
+ };
2077
+ box.querySelector('[data-lim-rm]')?.addEventListener('click', () => send({remove: true}, 'Removed.'));
2078
+ box.querySelector('[data-lim-off]')?.addEventListener('click', () => send({off: true}, 'The guard is off for this conversation.'));
2079
+ }
2080
+
1998
2081
  VIEWS.live = async (page) => {
1999
2082
  const [res, usage0] = await Promise.all([
2000
2083
  fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
@@ -2030,6 +2113,7 @@ VIEWS.live = async (page) => {
2030
2113
  <span class="pill">${esc(agentName(x.agent))}</span>
2031
2114
  ${esc((!MASKED && x.name) || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
2032
2115
  ${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
2116
+ ${x.agent === 'claude' ? limitBadge(x.session_id) : ''}
2033
2117
  <span class="spacer"></span>
2034
2118
  <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>
2035
2119
  <div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
@@ -2045,6 +2129,7 @@ VIEWS.live = async (page) => {
2045
2129
  <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>`}
2046
2130
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
2047
2131
  ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
2132
+ ${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
2048
2133
  <span class="live-msg"></span>
2049
2134
  </div>
2050
2135
  ${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
@@ -2059,6 +2144,7 @@ VIEWS.live = async (page) => {
2059
2144
  </div>
2060
2145
  <div class="ho-out note"></div>
2061
2146
  </div>`}
2147
+ ${x.agent === 'claude' && x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
2062
2148
  </div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
2063
2149
  $('#live-refresh', page).onclick = () => render();
2064
2150
  const ub = $('#usage-refresh', page);
@@ -2094,6 +2180,11 @@ VIEWS.live = async (page) => {
2094
2180
  }
2095
2181
  row.querySelectorAll('button.act:not(.ho-go):not(.ho-brief)').forEach(b => {
2096
2182
  if (b.dataset.ho) { b.onclick = () => { ho.hidden = !ho.hidden; if (!ho.hidden) ta.focus(); }; return; }
2183
+ if (b.dataset.lim) {
2184
+ const box = row.querySelector('.limitbox');
2185
+ b.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) drawLimit(box, x, +row.dataset.i); };
2186
+ return;
2187
+ }
2097
2188
  if (b.dataset.copy) { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); msg.textContent = 'Copied'; } catch { msg.textContent = b.dataset.copy; } }; return; }
2098
2189
  const label = b.textContent;
2099
2190
  b.onclick = async () => {
@@ -2327,22 +2418,395 @@ VIEWS.forecast = async (page) => {
2327
2418
  };
2328
2419
 
2329
2420
  /* ---------- budgets ---------- */
2421
+ /* ---------- budgets form helpers ---------- */
2422
+ // "20M", "500k", "$3,000", "2.5B" -> number; blank -> null. kind: 'tokens' | 'usd' | 'count'
2423
+ function parseAmount(raw, kind) {
2424
+ const s = String(raw ?? '').trim().replace(/[,\s_]/g, '').replace(/^\$/, '');
2425
+ if (s === '') return {value: null};
2426
+ const m = /^(\d+(?:\.\d+)?|\.\d+)([kmb])?$/i.exec(s);
2427
+ if (!m) return {error: kind === 'usd' ? 'Enter an amount like 3000, $3,000 or 3k.'
2428
+ : 'Enter a number like 20M, 500k or 2,000,000.'};
2429
+ const v = parseFloat(m[1]) * ({k: 1e3, m: 1e6, b: 1e9}[(m[2] || '').toLowerCase()] || 1);
2430
+ return {value: kind === 'usd' ? Math.round(v * 100) / 100 : Math.round(v)};
2431
+ }
2432
+ // Round up to `sig` significant figures (two by default), so suggestions are round numbers.
2433
+ const niceUp = (v, sig = 2) => {
2434
+ if (!(v > 0)) return 0;
2435
+ const p = 10 ** (Math.floor(Math.log10(v)) - sig + 1);
2436
+ return Math.ceil(v / p - 1e-9) * p;
2437
+ };
2438
+ // 8400000 -> "8.4M": compact, and parseAmount reads it back exactly for round numbers.
2439
+ function shortAmount(v) {
2440
+ const u = [[1e9, 'B'], [1e6, 'M'], [1e3, 'k']].find(([d]) => Math.abs(v) >= d);
2441
+ return u ? +(v / u[0]).toFixed(2) + u[1] : String(Math.round(v));
2442
+ }
2443
+ // items: a number, or [value, label, title]; data-driven ones are rounded up, zeros dropped
2444
+ function suggChips(items, fmt, sig = 2) {
2445
+ const seen = new Set();
2446
+ return items.map(it => Array.isArray(it) ? {v: niceUp(it[0], sig), l: it[1], t: it[2]} : {v: it})
2447
+ .filter(c => c.v > 0 && !seen.has(c.v) && seen.add(c.v))
2448
+ .map(c => ({...c, l: c.l ? `${c.l} · ${fmt(c.v)}` : fmt(c.v)}));
2449
+ }
2450
+ const usdChips = items => suggChips(items, v => '$' + shortAmount(v));
2451
+ const tokChips = (items, sig) => suggChips(items, shortAmount, sig);
2452
+
2453
+ function amountField({id, label, value, kind, zero, chips = [], hint = ''}) {
2454
+ const shown = value == null ? '' : kind === 'usd' ? String(value) : Number(value).toLocaleString('en-US');
2455
+ return `<div class="fld">
2456
+ <div class="hd"><label for="${id}">${label}</label></div>
2457
+ <input type="text" inputmode="decimal" id="${id}" data-kind="${kind}" ${zero ? 'data-zero="1"' : ''}
2458
+ value="${esc(shown)}" placeholder="not set" autocomplete="off" spellcheck="false"
2459
+ aria-describedby="${id}-hint ${id}-err">
2460
+ ${chips.length ? `<div class="sugg" aria-label="Suggestions">${chips.map(c =>
2461
+ `<button type="button" class="chip" data-fill="${id}" data-v="${c.v}" title="${esc(c.t || 'Use this value')}">${esc(c.l)}</button>`).join('')}</div>` : ''}
2462
+ <div class="fld-hint" id="${id}-hint">${hint}</div>
2463
+ <div class="fld-err" id="${id}-err" role="alert"></div>
2464
+ </div>`;
2465
+ }
2466
+ // The reason a typed amount is not acceptable, or '' when it is (blank is always fine).
2467
+ function amountError(el) {
2468
+ const r = parseAmount(el.value, el.dataset.kind);
2469
+ if (r.error) return r.error;
2470
+ if (r.value == null) return '';
2471
+ if (r.value < 0) return 'Cannot be negative.';
2472
+ if (r.value === 0 && !el.dataset.zero) return 'Must be more than 0, or leave it blank.';
2473
+ return '';
2474
+ }
2475
+ // Toggle chips for a list of percentages, plus a box to add another one.
2476
+ function pctField({id, label, values, presets, max}) {
2477
+ const all = [...new Set([...presets, ...values])].sort((a, b) => a - b);
2478
+ return `<div class="fld"><div class="hd">${label}</div>
2479
+ <div class="sugg" id="${id}" data-max="${max}">${all.map(p =>
2480
+ `<button type="button" class="chip ${values.includes(p) ? 'on' : ''}" data-pct="${p}" aria-pressed="${values.includes(p)}">${p}%</button>`).join('')}
2481
+ <input type="text" inputmode="decimal" class="pct-add" placeholder="+ other %" aria-label="Add another percentage"
2482
+ style="width:92px;padding:2px 8px;border-radius:99px">
2483
+ </div>
2484
+ <div class="fld-err" id="${id}-err" role="alert"></div></div>`;
2485
+ }
2486
+ function wirePct(page) {
2487
+ page.querySelectorAll('.cfg [data-max]').forEach(box => {
2488
+ const toggle = b => b.onclick = () => {
2489
+ b.classList.toggle('on'); b.setAttribute('aria-pressed', b.classList.contains('on'));
2490
+ clearErr(page, box.id);
2491
+ };
2492
+ box.querySelectorAll('[data-pct]').forEach(toggle);
2493
+ const add = box.querySelector('.pct-add');
2494
+ const commit = () => {
2495
+ const raw = add.value.trim().replace(/%$/, '');
2496
+ if (!raw) return;
2497
+ const v = +raw, max = +box.dataset.max;
2498
+ if (!Number.isFinite(v) || v < 1 || v > max) return setErr(page, box.id, `Enter a percentage from 1 to ${max}.`);
2499
+ let b = box.querySelector(`[data-pct="${v}"]`);
2500
+ if (!b) {
2501
+ b = h(`<button type="button" class="chip" data-pct="${v}" aria-pressed="false">${v}%</button>`);
2502
+ const after = [...box.querySelectorAll('[data-pct]')].find(x => +x.dataset.pct > v);
2503
+ box.insertBefore(b, after || add);
2504
+ toggle(b);
2505
+ }
2506
+ if (!b.classList.contains('on')) b.click();
2507
+ add.value = '';
2508
+ clearErr(page, box.id);
2509
+ };
2510
+ add.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); commit(); } };
2511
+ add.onblur = commit;
2512
+ });
2513
+ }
2514
+ function setErr(page, id, text) {
2515
+ const e = $('#' + id + '-err', page); if (e) e.textContent = text;
2516
+ const el = $('#' + id, page) || $('#' + id + '-tok', page);
2517
+ if (el && el.tagName !== 'DIV') el.classList.add('bad');
2518
+ }
2519
+ function clearErr(page, id) {
2520
+ const e = $('#' + id + '-err', page); if (e) e.textContent = '';
2521
+ [$('#' + id, page), $('#' + id + '-tok', page), $('#' + id + '-path', page)]
2522
+ .forEach(el => el && el.classList.remove('bad'));
2523
+ }
2524
+
2525
+ // API keys: status only ever comes back from the server, never the key itself.
2526
+ async function drawKeys(page) {
2527
+ const host = $('#cfg-keys', page);
2528
+ if (!host) return;
2529
+ let ks;
2530
+ try { ks = await fetch('/api/keys').then(r => r.json()); }
2531
+ catch (e) { host.innerHTML = '<div class="note">Could not read the key status.</div>'; return; }
2532
+ host.innerHTML = Object.entries(ks).map(([pid, k]) => `
2533
+ <div class="item">
2534
+ <div class="hd">${esc(k.name)}<span class="spacer"></span>
2535
+ <span class="badge ${k.source ? '' : 'na'}">${k.source === 'env' ? 'set by $' + esc(k.env)
2536
+ : k.source === 'stored' ? 'stored' + (k.last4 ? ' · ends ' + esc(k.last4) : '') : 'not set'}</span></div>
2537
+ <div class="dt"><b>Gives you:</b> ${esc(k.covers)}</div>
2538
+ <div class="dt"><b>Get a key:</b> ${esc(k.how)}</div>
2539
+ ${k.source === 'env'
2540
+ ? `<div class="fld-hint">The ${esc(k.env)} environment variable takes priority. Unset it to manage the key here.</div>`
2541
+ : `<div style="display:flex;gap:6px;align-items:center;margin-top:6px">
2542
+ <input type="password" id="key-${pid}" autocomplete="off" spellcheck="false" style="flex:1;min-width:0"
2543
+ aria-label="${esc(k.name)} key" placeholder="${k.source ? 'paste a new key to replace it' : 'paste the key'}">
2544
+ <button class="act" data-keysave="${pid}">Save key</button>
2545
+ ${k.source === 'stored' ? `<button class="act" data-keyrm="${pid}">Remove</button>` : ''}
2546
+ </div>`}
2547
+ <div class="fld-err" id="key-${pid}-err" role="alert"></div>
2548
+ <div class="fld-hint ok" id="key-${pid}-ok" role="status"></div>
2549
+ </div>`).join('');
2550
+ const done = async (pid, text) => {
2551
+ S.opts = await fetch('/api/options').then(r => r.json());
2552
+ applyAgentChrome(); // Billed vs local appears once a key exists
2553
+ await drawKeys(page);
2554
+ const ok = $('#key-' + pid + '-ok', page); if (ok) ok.textContent = text;
2555
+ };
2556
+ host.querySelectorAll('[data-keysave]').forEach(b => {
2557
+ const pid = b.dataset.keysave, inp = $('#key-' + pid, host), err = $('#key-' + pid + '-err', host);
2558
+ inp.oninput = () => { err.textContent = ''; inp.classList.remove('bad'); };
2559
+ inp.onkeydown = e => { if (e.key === 'Enter') b.click(); };
2560
+ b.onclick = async () => {
2561
+ if (!inp.value.trim()) { err.textContent = 'Paste a key first.'; inp.classList.add('bad'); return; }
2562
+ b.disabled = true;
2563
+ try {
2564
+ await doAction('key/' + pid, {value: inp.value});
2565
+ inp.value = '';
2566
+ await done(pid, 'Saved. Billed vs local uses it on the next Refresh.');
2567
+ } catch (e) { err.textContent = e.message; inp.classList.add('bad'); b.disabled = false; }
2568
+ };
2569
+ });
2570
+ host.querySelectorAll('[data-keyrm]').forEach(b => b.onclick = async () => {
2571
+ if (!b.dataset.armed) { // two clicks, like the other destructive actions
2572
+ b.dataset.armed = '1'; b.textContent = 'Click again to remove';
2573
+ setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.textContent = 'Remove'; } }, 4000);
2574
+ return;
2575
+ }
2576
+ const pid = b.dataset.keyrm;
2577
+ try { await doAction('key/' + pid, {remove: true}); await done(pid, 'Removed.'); }
2578
+ catch (e) { const err = $('#key-' + pid + '-err', host); if (err) err.textContent = e.message; }
2579
+ });
2580
+ }
2581
+
2582
+ /* ---------- guides: picture slides that explain a page ---------- */
2583
+ const GUIDES = {};
2584
+ let GUIDE = null; // {name, i, opener, el}
2585
+ const guideSeenKey = name => `finops.guide.${name}.seen`;
2586
+
2587
+ function openGuide(name, slideId, opener) {
2588
+ const slides = GUIDES[name];
2589
+ if (!slides || !slides.length) return;
2590
+ closeGuide();
2591
+ const el = document.createElement('div');
2592
+ el.className = 'guide-layer';
2593
+ el.innerHTML = `<div class="guide" role="dialog" aria-modal="true" aria-labelledby="guide-title" tabindex="-1"></div>`;
2594
+ document.body.appendChild(el);
2595
+ el.addEventListener('click', e => { if (e.target === el) closeGuide(); });
2596
+ GUIDE = {name, i: Math.max(0, slides.findIndex(s => s.id === slideId)), opener: opener || document.activeElement, el};
2597
+ document.addEventListener('keydown', guideKeys, true);
2598
+ try { localStorage.setItem(guideSeenKey(name), '1'); } catch (_) {}
2599
+ drawGuide();
2600
+ }
2601
+ function closeGuide() {
2602
+ if (!GUIDE) return;
2603
+ const {el, opener} = GUIDE;
2604
+ GUIDE = null;
2605
+ document.removeEventListener('keydown', guideKeys, true);
2606
+ el.remove();
2607
+ if (opener && opener.isConnected && opener.focus) opener.focus();
2608
+ }
2609
+ function guideKeys(e) {
2610
+ if (!GUIDE) return;
2611
+ e.stopPropagation();
2612
+ const slides = GUIDES[GUIDE.name];
2613
+ if (e.key === 'Escape') { e.preventDefault(); closeGuide(); }
2614
+ else if (e.key === 'ArrowRight' && GUIDE.i < slides.length - 1) { e.preventDefault(); GUIDE.i++; drawGuide(); }
2615
+ else if (e.key === 'ArrowLeft' && GUIDE.i > 0) { e.preventDefault(); GUIDE.i--; drawGuide(); }
2616
+ else if (e.key === 'Tab') { // keep focus inside the dialog
2617
+ const f = [...GUIDE.el.querySelectorAll('button:not([disabled]), a[href]')];
2618
+ if (!f.length) return;
2619
+ const first = f[0], last = f[f.length - 1];
2620
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
2621
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
2622
+ }
2623
+ }
2624
+ function drawGuide() {
2625
+ const slides = GUIDES[GUIDE.name], s = slides[GUIDE.i], n = slides.length, last = GUIDE.i === n - 1;
2626
+ const box = GUIDE.el.querySelector('.guide');
2627
+ box.innerHTML = `
2628
+ <header><span class="g-step">${GUIDE.i + 1} of ${n}</span>
2629
+ <h2 id="guide-title">${esc(s.title)}</h2>
2630
+ <button class="act g-x" data-g="close" aria-label="Close guide">${I('x')}</button></header>
2631
+ <div class="g-pic">${s.img
2632
+ ? `<span class="g-fig"><img src="${esc(s.img)}" alt="${esc(s.alt || '')}">${(s.marks || []).map(m =>
2633
+ `<span class="g-mark" style="left:${m.x}%;top:${m.y}%">${m.n}</span>`).join('')}</span>`
2634
+ : '<div class="g-ex">Example</div><div class="g-live"></div>'}</div>
2635
+ <div class="g-text" aria-live="polite">${s.text.map(t => `<p>${t}</p>`).join('')}</div>
2636
+ <footer>
2637
+ <div class="g-dots" aria-hidden="true">${slides.map((_, i) => `<i class="${i === GUIDE.i ? 'on' : ''}"></i>`).join('')}</div>
2638
+ <span class="spacer"></span>
2639
+ ${s.target ? '<button class="act" data-g="show">Show me on the page</button>' : ''}
2640
+ <button class="act" data-g="back" ${GUIDE.i ? '' : 'disabled'}>Back</button>
2641
+ <button class="chip on" data-g="${last ? 'close' : 'next'}">${last ? 'Done' : 'Next'}</button>
2642
+ </footer>`;
2643
+ if (s.live) s.live(box.querySelector('.g-live'));
2644
+ box.querySelectorAll('[data-g]').forEach(b => b.onclick = () => {
2645
+ const a = b.dataset.g;
2646
+ if (a === 'close') closeGuide();
2647
+ else if (a === 'next') { GUIDE.i++; drawGuide(); }
2648
+ else if (a === 'back') { GUIDE.i--; drawGuide(); }
2649
+ else if (a === 'show') { const t = s.target; closeGuide(); showMe(t); }
2650
+ });
2651
+ (box.querySelector('[data-g="next"], [data-g="close"]:not(.g-x)') || box).focus();
2652
+ }
2653
+ function showMe(selector) {
2654
+ const t = $(selector);
2655
+ if (!t) return;
2656
+ if (t.tagName === 'DETAILS') t.open = true;
2657
+ t.scrollIntoView({block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'});
2658
+ t.classList.remove('pulse'); void t.offsetWidth; t.classList.add('pulse');
2659
+ setTimeout(() => t.classList.remove('pulse'), 2200);
2660
+ }
2661
+ // First visit to a page with a guide: open it once. Never on top of the welcome tour.
2662
+ function maybeGuide(name) {
2663
+ let seen = true;
2664
+ try { seen = localStorage.getItem(guideSeenKey(name)) === '1'; } catch (_) {}
2665
+ if (seen || !GUIDES[name]) return;
2666
+ const tryOpen = () => {
2667
+ if (S.view !== name || GUIDE) return;
2668
+ try { if (localStorage.getItem(guideSeenKey(name)) === '1') return; } catch (_) {}
2669
+ if (TOUR) return setTimeout(tryOpen, 800); // wait for the welcome tour to finish
2670
+ openGuide(name);
2671
+ };
2672
+ setTimeout(tryOpen, 400);
2673
+ }
2674
+
2675
+ // A frozen copy of a form block for a guide picture: demo values, no ids that clash.
2676
+ function guideCopy(host, html) {
2677
+ host.innerHTML = `<div class="cfg">${html}</div>`;
2678
+ host.querySelectorAll('[id]').forEach(el => el.id = 'gcopy-' + el.id);
2679
+ host.querySelectorAll('[for]').forEach(el => el.setAttribute('for', 'gcopy-' + el.getAttribute('for')));
2680
+ host.querySelectorAll('[aria-describedby]').forEach(el => el.setAttribute('aria-describedby',
2681
+ el.getAttribute('aria-describedby').split(/\s+/).filter(Boolean).map(t => 'gcopy-' + t).join(' ')));
2682
+ host.querySelectorAll('input, select, button, textarea').forEach(el => { el.disabled = true; el.tabIndex = -1; });
2683
+ }
2684
+ const DEMO = {spend: 312, day: 14, busy: 29, tokens: 7.4e8, typical: 8.5e6, large: 3.3e7};
2685
+
2686
+ GUIDES.budgets = [
2687
+ {id: 'overview', title: 'What is this page?', img: 'guide/budgets.png',
2688
+ alt: 'The Budgets page: your results on one side, your limits on the other',
2689
+ marks: [{n: 1, x: 30, y: 8.5}, {n: 2, x: 96, y: 26}, {n: 3, x: 94, y: 51.5}],
2690
+ text: ['This page helps you stop spending too much on AI.',
2691
+ '<b>①</b> The results show how you are doing so far. <b>②</b> Here you set your money and token limits. <b>③</b> The session limit stops one conversation from getting too big. (On the real page the results are at the top and the limits are below them.)',
2692
+ 'You only need to fill in what you care about. Everything else can stay empty.'],
2693
+ target: '[data-blk="money"]'},
2694
+ {id: 'money', title: '① Money limits', target: '[data-blk="money"]',
2695
+ live: h => guideCopy(h, `<div class="grid g2">
2696
+ ${amountField({id: 'b-monthly', label: 'Monthly budget (USD)', kind: 'usd', value: 400,
2697
+ chips: usdChips([[DEMO.spend, 'Last 30 days'], 250, 500])})}
2698
+ ${amountField({id: 'b-daily', label: 'Daily budget (USD)', kind: 'usd', value: null,
2699
+ chips: usdChips([[DEMO.day, 'Average day'], [DEMO.busy, 'Busy day']])})}</div>`),
2700
+ text: ['Type the most money you want to spend in a month, or in a day.',
2701
+ 'Not sure? Tap a suggestion. They come from your own recent spending.',
2702
+ 'We warn you as you get close. Leave a box empty if you don\'t need it.']},
2703
+ {id: 'tokens', title: '② Token limits', target: '[data-blk="tokens"]',
2704
+ live: h => guideCopy(h, amountField({id: 'b-tokens', label: 'Monthly token budget', kind: 'tokens',
2705
+ value: 9e8, chips: tokChips([[DEMO.tokens, 'Last 30 days'], 1e9, 5e9])})),
2706
+ text: ['A token is a small piece of text, about ¾ of a word. Claude counts all its work in tokens.',
2707
+ 'Set how many tokens you want to use in a month. You can type short numbers like <b>900M</b> or <b>2B</b>.']},
2708
+ {id: 'session', title: '③ Session limit', target: '[data-blk="session"]',
2709
+ live: h => guideCopy(h, `<div class="grid g2">
2710
+ ${amountField({id: 'g-tokens', label: 'Per-session token budget', kind: 'tokens', value: 8e6,
2711
+ chips: tokChips([[DEMO.typical, 'Typical'], [DEMO.large, 'Large'], 5e6, 10e6])})}
2712
+ ${pctField({id: 'g-warn', label: 'Warn me at', values: [75, 80], presets: [50, 60, 70, 75, 80, 90], max: 99})}</div>`),
2713
+ text: ['A session is one Claude Code conversation.',
2714
+ 'Long conversations cost more, because Claude re-reads everything each time.',
2715
+ 'Set a limit for one conversation. Then pick when you want a warning, like at 75% and 80%.']},
2716
+ {id: 'after', title: 'After you say "continue", and project limits', target: '#g-projects',
2717
+ live: h => guideCopy(h, `<div class="grid g2">
2718
+ <div class="fld"><div class="hd">After I say "continue"</div>
2719
+ <label style="display:flex;gap:6px;margin:6px 0"><input type="radio" checked> Ask again every +25%</label>
2720
+ <label style="display:flex;gap:6px;margin:6px 0"><input type="radio"> Once per session</label></div>
2721
+ <div class="fld"><div class="hd">Different limit for a project</div>
2722
+ <div style="display:flex;gap:6px;align-items:center"><select><option>shop-app</option></select>
2723
+ <input type="text" value="15M" style="width:90px"><label><input type="checkbox"> off</label></div></div></div>`),
2724
+ text: ['When a conversation reaches its limit, you can let it keep going.',
2725
+ 'Choose if we ask you again a bit later, or never again for that conversation.',
2726
+ 'A big project can have its own, bigger limit. Or you can turn the limit off for it.']},
2727
+ {id: 'live', title: 'Live warnings (optional)', target: '#livebox',
2728
+ live: h => { h.innerHTML = `<div class="cfg"><div class="livebox"><div class="lb-head"><b>Live warnings in Claude Code</b>
2729
+ <span class="blk-sub">(optional)</span><span class="spacer"></span><span class="lb-state">○ Not installed</span>
2730
+ <button class="act" disabled>Install</button></div></div>
2731
+ <div class="g-cc">Session guard: this session has used 8.4M tokens, 105% of its 8M budget.
2732
+ Allow this tool call? <b>❯ Yes</b> &nbsp; No</div></div>`; },
2733
+ text: ['<b>You don\'t need this for your limits to work.</b>',
2734
+ 'Install it if you want Claude Code itself to warn you while you work, and to ask "continue?" when a conversation reaches its limit. The dark box shows what that looks like.',
2735
+ 'You can uninstall it any time.']},
2736
+ {id: 'advanced', title: 'Advanced (you can skip this)', target: '[data-blk="advanced"]',
2737
+ live: h => guideCopy(h, `<div class="grid g2">
2738
+ ${amountField({id: 'l-cost', label: 'Monthly cost allowance (USD)', kind: 'usd', zero: 1, value: null})}
2739
+ ${pctField({id: 't-thr', label: 'Warn when a budget reaches', values: [50, 75, 90, 100], presets: [25, 50, 75, 90, 100, 110], max: 1000})}</div>`),
2740
+ text: ['<b>Plan limits:</b> only fill these in if you know your plan\'s real numbers.',
2741
+ '<b>Alert thresholds:</b> when a money or token limit reaches these percentages, it gets marked.',
2742
+ 'Most people never need to change these.']},
2743
+ {id: 'save', title: 'Save, then check your results', target: '#savecfg',
2744
+ live: h => { h.innerHTML = `<div class="cfg"><div class="save-row"><button class="chip on" disabled>Save</button>
2745
+ <span class="ok">Saved.</span></div>
2746
+ <div class="meter high" style="margin:8px 0"><i style="width:82%"></i></div>
2747
+ <div class="fld-hint">Monthly spend · 82% used</div>
2748
+ <div style="margin-top:8px">${dot('yellow')} a conversation at 75% of its limit &nbsp; ${dot('red')} over its limit</div></div>`; },
2749
+ text: ['Press <b>Save</b>. Your limits are kept on this computer.',
2750
+ 'The top of this page then shows how you are doing against each limit.',
2751
+ 'On the <b>Sessions</b> page, amber and red dots show conversations near or over their limit.']},
2752
+ ];
2753
+
2754
+
2330
2755
  VIEWS.budgets = async (page) => {
2331
2756
  const b = await api('budgets');
2332
2757
  const st = S.opts.settings;
2758
+ const sg = st.guard || {};
2759
+ // suggestions from your own recent usage (see Analytics.budget_suggestions)
2760
+ const SG = b.suggest || {}, sizes = SG.session_tokens || [];
2761
+ const pctile = q => sizes.length ? sizes[Math.min(sizes.length - 1, Math.floor(q * sizes.length))] : 0;
2762
+ const overShare = v => {
2763
+ let lo = 0, hi = sizes.length;
2764
+ while (lo < hi) { const m = (lo + hi) >> 1; if (sizes[m] > v) hi = m; else lo = m + 1; }
2765
+ const n = sizes.length - lo;
2766
+ return n ? `${fmtInt(n)} of your ${fmtInt(sizes.length)} past sessions (${fmtPct(100 * n / sizes.length, 0)}) went over this.`
2767
+ : `None of your ${fmtInt(sizes.length)} past sessions went over this.`;
2768
+ };
2769
+ const HINTS = {
2770
+ 'b-monthly': v => v ? `At your last-30-days pace (${fmtUSD(SG.spend_30d)}) you would use ${fmtPct(100 * SG.spend_30d / v, 0)} of this.`
2771
+ : `Last 30 days: ${fmtUSD(SG.spend_30d)} (estimated).`,
2772
+ 'b-daily': () => `Your average day is ${fmtUSD(SG.daily_avg)}; 1 day in 10 costs more than ${fmtUSD(SG.daily_p90)}.`,
2773
+ 'b-tokens': v => v ? `The last 30 days used ${fmtNum(SG.tokens_30d)} tokens, ${fmtPct(100 * SG.tokens_30d / v, 0)} of this.`
2774
+ : `Last 30 days: ${fmtNum(SG.tokens_30d)} tokens.`,
2775
+ 'g-tokens': v => !sizes.length ? '' : v ? overShare(v) : `Half your sessions stay under ${fmtNum(pctile(.5))} tokens.`,
2776
+ };
2777
+ const planHint = `Your own figure, from your plan or invoice. ${esc(agentWord())} data does not include it.`;
2778
+ const stepVal = sg.step_pct ?? 25;
2779
+ // Does any per-session budget exist (global or a project override)? The hook needs one.
2780
+ const anyBudget = !!sg.session_tokens || [sg.projects, sg.sessions].some(m => Object.values(m || {}).some(o => o.session_tokens));
2781
+ const liveBox = () => {
2782
+ const on = !!S.opts.guard_installed;
2783
+ const state = !on ? `<span class="lb-state">○ Not installed</span>`
2784
+ : anyBudget ? `<span class="lb-state on">● Installed · works in new Claude Code sessions</span>`
2785
+ : `<span class="lb-state warn">Installed, but it does nothing until you set a per-session token budget above.</span>`;
2786
+ return `<div class="livebox" id="livebox">
2787
+ <div class="lb-head"><b>Live warnings in Claude Code</b> <span class="blk-sub">(optional)</span>
2788
+ <span class="spacer"></span>${state}
2789
+ <button class="act" id="g-install">${on ? 'Uninstall' : 'Install'}</button></div>
2790
+ <p>Your limits above already work without this. Install it if you want Claude Code itself to warn
2791
+ you at your warn percentages, and ask "continue?" when a conversation reaches its limit.</p>
2792
+ <p class="blk-sub">It is a small helper that runs inside Claude Code. Needs: a per-session token budget.
2793
+ It cannot end a conversation, and if it ever fails it lets Claude carry on. Undo any time.</p>
2794
+ <div class="fld-hint" id="g-msg" role="status"></div></div>`;
2795
+ };
2333
2796
  page.innerHTML = `
2334
- ${card('Budget vs actual vs forecast', `<div class="stack">${b.lines.map(l => l.configured ? `
2797
+ ${card('Budget vs actual vs forecast', `<p class="blk-intro" style="margin-top:0">How you're doing against your limits. Set your limits below.</p><div class="stack">${b.lines.map(l => l.configured ? `
2335
2798
  <div class="item">
2336
2799
  <div class="hd">${esc(l.name)}<span class="spacer"></span>${statusChip(l.status)}</div>
2337
2800
  <div class="grid g4" style="gap:8px;margin:4px 0">
2338
2801
  ${kpi('Budget', l.unit === 'tokens' ? fmtNum(l.budget) : fmtUSD(l.budget), null, {small: 1})}
2339
2802
  ${kpi('Actual', l.unit === 'tokens' ? fmtNum(l.actual) : fmtUSD(l.actual),
2340
2803
  fmtPct(l.used_pct) + ' used', {small: 1, badge: BADGE.estimated})}
2341
- ${kpi('Forecast', l.forecast == null ? null
2804
+ ${kpi('Forecast', l.sessions_over != null ? '—' : l.forecast == null ? null
2342
2805
  : (l.unit === 'tokens' ? fmtNum(l.forecast) : fmtUSD(l.forecast)),
2343
- l.forecast_pct == null ? '' : fmtPct(l.forecast_pct) + ' of budget',
2806
+ l.sessions_over != null ? 'not forecast per session'
2807
+ : l.forecast_pct == null ? '' : fmtPct(l.forecast_pct) + ' of budget',
2344
2808
  {small: 1, badge: BADGE.forecast})}
2345
- ${kpi('Variance', l.variance == null ? null
2809
+ ${kpi('Variance', l.sessions_over != null ? '—' : l.variance == null ? null
2346
2810
  : (l.variance >= 0 ? '+' : '') + (l.unit === 'tokens' ? fmtNum(l.variance) : fmtUSD(l.variance)),
2347
2811
  l.variance == null ? '' : (l.variance > 0 ? 'over budget' : 'under budget'), {small: 1})}
2348
2812
  </div>
@@ -2351,6 +2815,9 @@ VIEWS.budgets = async (page) => {
2351
2815
  ${l.thresholds_breached.length ? `<span>${I('alert')} breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2352
2816
  ${l.thresholds_forecast_breach.length
2353
2817
  ? `<span style="color:var(--serious-ink)">forecast to breach ${l.thresholds_forecast_breach.join('%, ')}%</span>` : ''}
2818
+ ${l.sessions_over == null ? '' : `<span title="Most of a long session's tokens are cache reads, priced at about a tenth of the input rate.">
2819
+ ${l.sessions_over ? `<a href="#" data-over="1">${fmtInt(l.sessions_over)} session${l.sessions_over === 1 ? '' : 's'} over budget this period</a>`
2820
+ : 'No session over budget this period'} · largest session shown</span>`}
2354
2821
  </div></div>`
2355
2822
  : `<div class="item"><div class="hd">${esc(l.name)}<span class="spacer"></span>
2356
2823
  <span class="badge na">not configured</span></div>
@@ -2358,57 +2825,238 @@ VIEWS.budgets = async (page) => {
2358
2825
  (estimated). Set a budget below to track variance and get threshold warnings.</div></div>`
2359
2826
  ).join('')}</div>`, {badge: BADGE.estimated,
2360
2827
  hint: `alert thresholds: ${b.thresholds_pct.join('%, ')}%`})}
2361
- ${card('Configure budgets, limits and thresholds', `
2362
- <div class="grid g3">
2363
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Budgets</div>
2364
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2365
- <div class="hd">Monthly budget (USD)</div>
2366
- <input type="number" step="1" id="b-monthly" value="${st.budgets.monthly_usd ?? ''}" placeholder="not set">
2367
- <div class="hd">Daily budget (USD)</div>
2368
- <input type="number" step="0.5" id="b-daily" value="${st.budgets.daily_usd ?? ''}" placeholder="not set">
2369
- <div class="hd">Monthly token budget</div>
2370
- <input type="number" id="b-tokens" value="${st.budgets.monthly_tokens ?? ''}" placeholder="not set">
2371
- </label></div>
2372
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Plan limits</div>
2373
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2374
- <div class="hd">Monthly cost allowance (USD)</div>
2375
- <input type="number" step="1" id="l-cost" value="${st.limits.monthly_cost_allowance_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
2376
- <div class="hd">Monthly token allowance</div>
2377
- <input type="number" id="l-tok" value="${st.limits.monthly_token_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
2378
- <div class="hd">Monthly request allowance</div>
2379
- <input type="number" id="l-req" value="${st.limits.monthly_request_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
2380
- <div class="hd">Remaining credits (USD)</div>
2381
- <input type="number" step="0.01" id="l-cred" value="${st.limits.remaining_credits_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
2382
- </label></div>
2383
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Alert thresholds</div>
2384
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2385
- <div class="hd">Comma-separated %</div>
2386
- <input type="text" id="t-thr" value="${st.alert_thresholds_pct.join(',')}"
2387
- style="width:100%;padding:5px 7px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2)">
2388
- </label>
2389
- <button class="chip on" id="savecfg" style="margin-top:10px;width:100%;justify-content:center">Save configuration</button>
2390
- <div class="note" style="margin-top:8px">Writes to <span class="mono">config/settings.json</span>.
2391
- Leave a field blank to keep it unconfigured — the dashboard will report it as unavailable
2392
- rather than inventing a value.</div>
2828
+ ${card('Set your limits', `<div class="cfg">
2829
+ <section class="blk" data-blk="money">
2830
+ <h4><span class="num">①</span> Money limits <button type="button" class="blk-help" data-guide="money" aria-label="Help: money limits">?</button></h4>
2831
+ <p class="blk-intro">Set the most money you want to spend. We warn you before you go over.</p>
2832
+ <div class="grid g2">
2833
+ ${amountField({id: 'b-monthly', label: 'Monthly budget (USD)', kind: 'usd', value: st.budgets.monthly_usd,
2834
+ chips: usdChips([[SG.spend_30d, 'Last 30 days', 'Your estimated spend over the last 30 days, rounded up'],
2835
+ [SG.spend_30d * 1.1, 'Last 30 days +10%', 'Some headroom over your recent spend'],
2836
+ 1000, 2500, 5000])})}
2837
+ ${amountField({id: 'b-daily', label: 'Daily budget (USD)', kind: 'usd', value: st.budgets.daily_usd,
2838
+ chips: usdChips([[SG.daily_avg, 'Average day', 'Your average day over the last 30 days, rounded up'],
2839
+ [SG.daily_p90, 'Busy day', '1 day in 10 costs more than this'], 50, 100, 250])})}
2840
+ </div>
2841
+ </section>
2842
+ <section class="blk" data-blk="tokens">
2843
+ <h4><span class="num">②</span> Token limits <button type="button" class="blk-help" data-guide="tokens" aria-label="Help: token limits">?</button></h4>
2844
+ <p class="blk-intro">Tokens are the small pieces of text Claude reads and writes (about ¾ of a word).
2845
+ Set how many you want to use in a month.</p>
2846
+ <div class="grid g2">
2847
+ ${amountField({id: 'b-tokens', label: 'Monthly token budget', kind: 'tokens', value: st.budgets.monthly_tokens,
2848
+ chips: tokChips([[SG.tokens_30d, 'Last 30 days', 'Billable tokens over the last 30 days, rounded up'],
2849
+ 1e9, 5e9, 10e9])})}
2850
+ <div></div>
2393
2851
  </div>
2394
- </div>`, {footer: `Plan allowances are NOT available from ${agentWord()} data. Anything you enter here is your own declared figure, used only to compute usage-vs-limit and days-until-limit.`})}`;
2852
+ </section>
2853
+ <section class="blk" data-blk="session">
2854
+ <h4><span class="num">③</span> Session limit <button type="button" class="blk-help" data-guide="session" aria-label="Help: session limit">?</button></h4>
2855
+ <p class="blk-intro">A session is one Claude Code conversation. Stop one conversation from getting too big.</p>
2856
+ <div class="grid g3">
2857
+ <div>
2858
+ ${amountField({id: 'g-tokens', label: 'Per-session token budget', kind: 'tokens', value: sg.session_tokens,
2859
+ chips: tokChips([[pctile(.75), 'Typical', '3 in 4 of your sessions stay under this'],
2860
+ [pctile(.9), 'Large', '9 in 10 of your sessions stay under this'],
2861
+ 5e6, 10e6, 25e6, 50e6])})}
2862
+ ${pctField({id: 'g-warn', label: 'Warn me at', values: sg.warn_pct || [], presets: [50, 60, 70, 75, 80, 90], max: 99})}
2863
+ </div>
2864
+ <div>
2865
+ <div class="fld"><div class="hd">After I say "continue"</div>
2866
+ <label style="display:flex;gap:6px;align-items:center;margin:6px 0;flex-wrap:wrap">
2867
+ <input type="radio" name="g-after" value="step" ${sg.after_approval !== 'once' ? 'checked' : ''}>
2868
+ Ask again every
2869
+ <select id="g-step-sel" style="width:auto">
2870
+ ${[10, 25, 50, 100].map(v => `<option value="${v}" ${stepVal === v ? 'selected' : ''}>+${v}%</option>`).join('')}
2871
+ <option value="other" ${[10, 25, 50, 100].includes(stepVal) ? '' : 'selected'}>other…</option>
2872
+ </select>
2873
+ <input type="text" inputmode="decimal" id="g-step" value="${stepVal}" placeholder="%"
2874
+ style="width:64px;${[10, 25, 50, 100].includes(stepVal) ? 'display:none' : ''}"></label>
2875
+ <label style="display:flex;gap:6px;align-items:center;margin:6px 0">
2876
+ <input type="radio" name="g-after" value="once" ${sg.after_approval === 'once' ? 'checked' : ''}>
2877
+ Once per session</label>
2878
+ <div class="fld-err" id="g-step-err" role="alert"></div>
2879
+ </div>
2880
+ </div>
2881
+ <div>
2882
+ <div class="fld"><div class="hd">Different limit for a project</div>
2883
+ <div id="g-projects"></div>
2884
+ <datalist id="gtok-list">${[pctile(.75), pctile(.9), 5e6, 10e6, 25e6, 50e6].filter(v => v > 0)
2885
+ .map(v => `<option value="${shortAmount(niceUp(v))}">`).join('')}</datalist>
2886
+ <button class="act" id="g-add" style="margin-top:6px">${I('plus')} Add override</button>
2887
+ </div>
2888
+ </div>
2889
+ </div>
2890
+ ${liveBox()}
2891
+ </section>
2892
+ <details class="blk adv" data-blk="advanced">
2893
+ <summary><h4>Advanced: plan limits and alert thresholds <span class="blk-sub">(most people can skip this)</span>
2894
+ <button type="button" class="blk-help" data-guide="advanced" aria-label="Help: advanced">?</button></h4></summary>
2895
+ <div class="grid g2">
2896
+ <div><div class="sec">Plan limits</div>
2897
+ <div class="fld-hint" style="margin:-2px 2px 6px">${planHint}</div>
2898
+ ${amountField({id: 'l-cost', label: 'Monthly cost allowance (USD)', kind: 'usd', zero: 1,
2899
+ value: st.limits.monthly_cost_allowance_usd})}
2900
+ ${amountField({id: 'l-tok', label: 'Monthly token allowance', kind: 'tokens', zero: 1,
2901
+ value: st.limits.monthly_token_allowance})}
2902
+ ${amountField({id: 'l-req', label: 'Monthly request allowance', kind: 'count', zero: 1,
2903
+ value: st.limits.monthly_request_allowance})}
2904
+ ${amountField({id: 'l-cred', label: 'Remaining credits (USD)', kind: 'usd', zero: 1,
2905
+ value: st.limits.remaining_credits_usd})}
2906
+ </div>
2907
+ <div><div class="sec">Alert thresholds</div>
2908
+ <p class="blk-intro">When a money or token limit reaches these percentages, the dashboard marks it.</p>
2909
+ ${pctField({id: 't-thr', label: 'Warn when a budget reaches', values: st.alert_thresholds_pct,
2910
+ presets: [25, 50, 75, 90, 100, 110], max: 1000})}
2911
+ </div>
2912
+ </div>
2913
+ </details>
2914
+ <div class="save-row">
2915
+ <button class="chip on" id="savecfg">Save</button><span id="cfg-msg" role="status"></span>
2916
+ <span class="fld-hint">Saved on this computer only. Amounts take shorthand: 20M, 500k, $3,000.</span>
2917
+ </div>
2918
+ </div>`, {actions: `<button class="act" id="guide-open">${I('help')} How does this work?</button>`})}`;
2919
+ // amounts: chips fill the field; hints follow what is typed; errors clear as you edit
2920
+ page.querySelectorAll('.cfg [data-fill]').forEach(c => c.onclick = () => {
2921
+ const el = $('#' + c.dataset.fill, page);
2922
+ el.value = el.dataset.kind === 'usd' ? String(+c.dataset.v) : shortAmount(+c.dataset.v);
2923
+ el.dispatchEvent(new Event('input'));
2924
+ });
2925
+ page.querySelectorAll('.cfg input[data-kind]').forEach(el => {
2926
+ const upd = () => {
2927
+ clearErr(page, el.id);
2928
+ const hint = HINTS[el.id], h = $('#' + el.id + '-hint', page);
2929
+ if (hint && h) { const r = parseAmount(el.value, el.dataset.kind); h.textContent = r.error ? '' : hint(r.value); }
2930
+ };
2931
+ el.addEventListener('input', upd);
2932
+ el.addEventListener('blur', () => { const e = amountError(el); if (e) setErr(page, el.id, e); });
2933
+ upd();
2934
+ });
2935
+ wirePct(page);
2936
+ const stepSel = $('#g-step-sel', page), stepIn = $('#g-step', page);
2937
+ stepSel.onchange = () => {
2938
+ stepIn.style.display = stepSel.value === 'other' ? '' : 'none';
2939
+ if (stepSel.value !== 'other') stepIn.value = stepSel.value; else stepIn.focus();
2940
+ clearErr(page, 'g-step');
2941
+ };
2942
+ // project overrides: [{path, off, tokens}], edited in place, saved with the rest
2943
+ const gp = Object.entries(sg.projects || {}).map(([path, o]) =>
2944
+ ({path, off: !!o.off, tokens: o.session_tokens == null ? '' : o.session_tokens.toLocaleString('en-US')}));
2945
+ const known = [...new Map((S.opts.projects || [])
2946
+ .filter(p => p.path && (p.agent || 'claude') === 'claude' && !p.is_sandbox)
2947
+ .map(p => [p.path, p])).values()].sort((a, b) => a.name.localeCompare(b.name));
2948
+ const drawProjects = () => {
2949
+ $('#g-projects', page).innerHTML = gp.length ? gp.map((o, i) => `
2950
+ <div style="display:flex;gap:6px;align-items:center;margin:4px 0">
2951
+ <select data-gp="${i}" data-k="path" id="gp-${i}-path" style="flex:1;min-width:0">
2952
+ <option value="">choose a project</option>
2953
+ ${[...new Set([o.path, ...known.map(p => p.path)].filter(Boolean))].map(path => {
2954
+ const p = known.find(k => k.path === path);
2955
+ return `<option value="${esc(path)}" ${path === o.path ? 'selected' : ''} title="${esc(path)}">${esc(p ? p.name : path)}</option>`;
2956
+ }).join('')}
2957
+ </select>
2958
+ <input type="text" inputmode="decimal" list="gtok-list" data-gp="${i}" data-k="tokens" id="gp-${i}-tok"
2959
+ value="${esc(o.tokens)}" placeholder="budget, e.g. 20M" style="width:130px;flex:none" ${o.off ? 'disabled' : ''}>
2960
+ <label style="display:flex;gap:3px;align-items:center"><input type="checkbox" data-gp="${i}" data-k="off" ${o.off ? 'checked' : ''}>off</label>
2961
+ <button class="act" data-gp-rm="${i}" title="Remove override" aria-label="Remove override">${I('x')}</button>
2962
+ </div>
2963
+ <div class="fld-err" id="gp-${i}-err" role="alert"></div>`).join('')
2964
+ : '<div class="fld-hint">None: every project uses the per-session budget.</div>';
2965
+ page.querySelectorAll('[data-gp]').forEach(el => el[el.type === 'text' ? 'oninput' : 'onchange'] = () => {
2966
+ const o = gp[+el.dataset.gp], k = el.dataset.k;
2967
+ o[k] = k === 'off' ? el.checked : el.value;
2968
+ clearErr(page, `gp-${el.dataset.gp}`);
2969
+ if (k === 'off') drawProjects();
2970
+ });
2971
+ page.querySelectorAll('[data-gp-rm]').forEach(b => b.onclick = () => { gp.splice(+b.dataset.gpRm, 1); drawProjects(); });
2972
+ };
2973
+ drawProjects();
2974
+ $('#g-add', page).onclick = () => { gp.push({path: '', off: false, tokens: ''}); drawProjects(); };
2975
+ $('#g-install', page).onclick = async () => {
2976
+ const msg = $('#g-msg', page);
2977
+ try {
2978
+ const r = await doAction('guard', {remove: !!S.opts.guard_installed});
2979
+ S.opts = await fetch('/api/options').then(r => r.json());
2980
+ bust(); await render();
2981
+ const m = $('#g-msg'); if (m) m.textContent = r.message;
2982
+ } catch (e) { msg.textContent = e.message; }
2983
+ };
2984
+ page.querySelectorAll('[data-over]').forEach(a => a.onclick = e => {
2985
+ e.preventDefault(); S.sessOrder = 'tokens'; S.sessPage.offset = 0; bust(); go('sessions'); });
2395
2986
  $('#savecfg', page).onclick = async () => {
2396
- const v = id => { const x = $('#' + id, page).value.trim(); return x === '' ? null : +x; };
2397
- await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
2398
- body: JSON.stringify({
2399
- budgets: {monthly_usd: v('b-monthly'), daily_usd: v('b-daily'), monthly_tokens: v('b-tokens')},
2400
- limits: {monthly_cost_allowance_usd: v('l-cost'), monthly_token_allowance: v('l-tok'),
2401
- monthly_request_allowance: v('l-req'), remaining_credits_usd: v('l-cred')},
2402
- alert_thresholds_pct: $('#t-thr', page).value.split(',').map(x => +x.trim()).filter(Boolean),
2403
- })}).then(r => r.json());
2987
+ const msg = $('#cfg-msg', page);
2988
+ msg.className = ''; msg.textContent = '';
2989
+ page.querySelectorAll('.cfg .fld-err').forEach(e => { if (!e.id.startsWith('key-')) e.textContent = ''; });
2990
+ page.querySelectorAll('.cfg .bad').forEach(e => e.classList.remove('bad'));
2991
+ const errs = [];
2992
+ const amt = id => { const el = $('#' + id, page), e = amountError(el); if (e) errs.push([id, e]);
2993
+ return e ? null : parseAmount(el.value, el.dataset.kind).value; };
2994
+ const pcts = id => [...page.querySelectorAll(`#${id} [data-pct].on`)].map(b => +b.dataset.pct).sort((a, b) => a - b);
2995
+ const budgets = {monthly_usd: amt('b-monthly'), daily_usd: amt('b-daily'), monthly_tokens: amt('b-tokens')};
2996
+ const limits = {monthly_cost_allowance_usd: amt('l-cost'), monthly_token_allowance: amt('l-tok'),
2997
+ monthly_request_allowance: amt('l-req'), remaining_credits_usd: amt('l-cred')};
2998
+ const thr = pcts('t-thr');
2999
+ if (!thr.length) errs.push(['t-thr', 'Pick at least one percentage.']);
3000
+ const after = (page.querySelector('[name=g-after]:checked') || {}).value || 'step';
3001
+ let step = stepSel.value === 'other' ? parseAmount(stepIn.value, 'count').value : +stepSel.value;
3002
+ if (after === 'step' && !(step >= 1 && step <= 1000)) errs.push(['g-step', 'Enter a step between 1 and 1000%.']);
3003
+ if (!(step >= 1 && step <= 1000)) step = 25;
3004
+ const projects = {};
3005
+ gp.forEach((o, i) => {
3006
+ const id = `gp-${i}`;
3007
+ if (!o.path) return errs.push([id, 'Pick a project, or remove this row.']);
3008
+ if (projects[o.path]) return errs.push([id, 'This project already has an override above.']);
3009
+ if (o.off) return (projects[o.path] = {off: true});
3010
+ const r = parseAmount(o.tokens, 'tokens');
3011
+ if (r.error) return errs.push([id, r.error]);
3012
+ if (!(r.value > 0)) return errs.push([id, 'Enter a budget, or tick off.']);
3013
+ projects[o.path] = {session_tokens: r.value};
3014
+ });
3015
+ const guard = {session_tokens: amt('g-tokens'), warn_pct: pcts('g-warn'), after_approval: after,
3016
+ step_pct: step, projects};
3017
+ if (errs.length) {
3018
+ errs.forEach(([id, e]) => setErr(page, id, e));
3019
+ page.querySelectorAll('details.adv').forEach(d => { if (d.querySelector('.fld-err:not(:empty)')) d.open = true; });
3020
+ msg.className = 'err';
3021
+ msg.textContent = `Fix ${errs.length} field${errs.length === 1 ? '' : 's'} before saving.`;
3022
+ const first = $('#' + errs[0][0], page) || $('#' + errs[0][0] + '-err', page);
3023
+ if (first) { first.scrollIntoView({block: 'center', behavior: 'smooth'}); first.focus && first.focus(); }
3024
+ return;
3025
+ }
3026
+ let res;
3027
+ try {
3028
+ res = await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
3029
+ body: JSON.stringify({budgets, limits, alert_thresholds_pct: thr, guard})}).then(r => r.json());
3030
+ } catch (e) { res = {error: 'Could not reach the dashboard server. Is it still running?'}; }
3031
+ if (res.error) { msg.className = 'err'; msg.textContent = 'Not saved: ' + res.error; return; }
2404
3032
  S.opts = await fetch('/api/options').then(r => r.json());
2405
- bust(); render();
3033
+ bust(); await render();
3034
+ const m = $('#cfg-msg'); if (m) { m.className = 'ok'; m.textContent = 'Saved.'; }
2406
3035
  };
2407
3036
  const bl = b.lines.filter(l => l.configured && l.budget);
2408
3037
  if (bl.length) addChart(page, 'Budget used', el => C.barsH(el, {
2409
3038
  rows: bl, label: l => l.name, value: l => 100 * l.actual / l.budget, fmt: v => fmtPct(v), max: 100,
2410
3039
  color: l => l.actual >= l.budget ? 'var(--critical)' : l.actual >= 0.75 * l.budget ? 'var(--warning)' : seriesVar(2)}),
2411
3040
  {badge: BADGE.estimated, after: '.nothing'});
3041
+ $('#guide-open', page).onclick = e => openGuide('budgets', null, e.currentTarget);
3042
+ page.querySelectorAll('[data-guide]').forEach(b => b.onclick = e => {
3043
+ e.preventDefault(); e.stopPropagation(); // inside <summary>: don't toggle Advanced
3044
+ openGuide('budgets', b.dataset.guide, b);
3045
+ });
3046
+ maybeGuide('budgets');
3047
+ };
3048
+
3049
+ /* ---------- settings ---------- */
3050
+ VIEWS.settings = async (page) => {
3051
+ page.innerHTML = card('API keys', `<div class="cfg">
3052
+ <div class="fld-hint" style="margin-bottom:8px">An API key is a password that lets this dashboard ask
3053
+ Anthropic or Cursor for your company's bill. You only need one for the <b>Billed vs local</b> page,
3054
+ which compares what was billed with what this computer recorded. That page shows up in the sidebar
3055
+ once a key is saved. Skip this if you don't use it. Keys stay on this computer, in a file only you
3056
+ can read. They are never shown again.</div>
3057
+ <div id="cfg-keys" class="stack"><div class="note">Loading…</div></div></div>`,
3058
+ {hint: 'optional'});
3059
+ drawKeys(page);
2412
3060
  };
2413
3061
 
2414
3062
  /* ---------- scorecard ---------- */
@@ -2428,7 +3076,7 @@ VIEWS.scorecard = async (page) => {
2428
3076
  : d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div>
2429
3077
  <div class="note">${esc(d.detail)}</div></div>`).join('')}
2430
3078
  </div></div>`, {badge: BADGE.estimated,
2431
- footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
3079
+ footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget on the Budgets page.'})}
2432
3080
  <div class="grid g3">
2433
3081
  ${card('What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2434
3082
  sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
@@ -3216,6 +3864,121 @@ VIEWS.compare = async (page) => {
3216
3864
  };
3217
3865
 
3218
3866
  /* ---------- skills & MCP from recurring work ---------- */
3867
+ /* ---------- jev: TypeSafe's fast decision model ---------- */
3868
+ let JEV_MSG = null; // {where: 'install'|'key', ok, text}: shown once after the page redraws
3869
+ VIEWS.jev = async (page) => {
3870
+ const d = await api('jev');
3871
+ const st = d.status, k = d.key, fit = d.fit;
3872
+ const flash = where => JEV_MSG && JEV_MSG.where === where
3873
+ ? `<div class="fld-hint ${JEV_MSG.ok ? 'ok' : ''}" style="${JEV_MSG.ok ? '' : 'color:var(--critical-ink)'}">${esc(JEV_MSG.text)}</div>` : '';
3874
+ const state = !st.claude
3875
+ ? `<span class="lb-state warn">Claude Code's <code>claude</code> command was not found on this computer, so Jev can't be installed from here.</span>`
3876
+ : st.installed ? `<span class="lb-state on">● Installed${st.version ? ' · version ' + esc(st.version) : ''}${st.enabled === false ? ' (turned off in Claude Code)' : ''}</span>`
3877
+ : '<span class="lb-state">○ Not installed</span>';
3878
+ const keyState = k.source === 'shell' ? `set in your shell${k.last4 ? ' · ends ' + esc(k.last4) : ''}`
3879
+ : k.source ? `saved${k.last4 ? ' · ends ' + esc(k.last4) : ''}` : 'not set';
3880
+ const small = fit.share_pct < 1;
3881
+ const usd = v => v ? fmtUSD(v) : '$0';
3882
+ page.innerHTML = `
3883
+ ${card('What is Jev?', `<div class="cfg">
3884
+ <p class="blk-intro">Jev is a very fast, very cheap AI that makes decisions. It picks one option, answers yes or no,
3885
+ gives a score, or pulls out a value. It can't write text or code.</p>
3886
+ <p class="blk-intro">It doesn't make Claude Code itself cheaper. It makes the apps and scripts you build cheaper, by
3887
+ replacing AI calls that only make a decision, like sorting tickets or checking a pull request.</p>
3888
+ <p class="blk-intro">Installing it teaches Claude how to use Jev when you build those.
3889
+ <a href="https://docs.typesafe.ai" target="_blank" rel="noopener">Read Jev's docs</a></p></div>`,
3890
+ {hint: 'made by TypeSafe AI'})}
3891
+ ${card('Set up Jev', `<div class="cfg">
3892
+ <section class="blk">
3893
+ <h4><span class="num">①</span> Install the Jev plugin for Claude Code</h4>
3894
+ <div class="lb-head">${state}<span class="spacer"></span>
3895
+ ${st.claude && !st.installed ? '<button class="act" id="jev-install">Install</button>' : ''}
3896
+ ${st.installed ? '<button class="act" id="jev-uninstall">Uninstall</button>' : ''}</div>
3897
+ <div class="livebox" id="jev-confirm" hidden>
3898
+ <p><b>This adds TypeSafe's plugin to Claude Code for all your projects.</b> It runs these two commands:</p>
3899
+ <pre class="mono" style="margin:6px 0;white-space:pre-wrap">claude plugin marketplace add typesafe-ai/skills
3900
+ claude plugin install typesafe@typesafe-ai</pre>
3901
+ <p class="blk-sub">Jev only sees what the apps you build send it. Uninstall any time.</p>
3902
+ <div class="live-actions"><button class="chip on" id="jev-go">Confirm install</button>
3903
+ <button class="act ghost" id="jev-cancel">Cancel</button></div></div>
3904
+ <div id="jev-log"></div>${flash('install')}
3905
+ </section>
3906
+ <section class="blk">
3907
+ <h4><span class="num">②</span> Add your Jev API key</h4>
3908
+ <p class="blk-intro">The key lets your apps and Claude talk to Jev. Get one at
3909
+ <a href="https://console.typesafe.ai" target="_blank" rel="noopener">console.typesafe.ai</a>.</p>
3910
+ <div class="lb-head"><span class="lb-state ${k.source ? 'on' : ''}">Key: ${keyState}</span></div>
3911
+ <div style="display:flex;gap:6px;align-items:center;margin-top:6px">
3912
+ <input type="password" id="jev-key" autocomplete="off" spellcheck="false" style="flex:1;min-width:0"
3913
+ aria-label="Jev API key" placeholder="${k.source ? 'paste a new key to replace it' : 'paste your Jev API key'}">
3914
+ <button class="act" id="jev-key-save">Save key</button>
3915
+ ${k.source === 'claude_settings' ? '<button class="act" id="jev-key-rm">Remove</button>' : ''}</div>
3916
+ <div class="fld-err" id="jev-key-err" role="alert"></div>${flash('key')}
3917
+ <p class="fld-hint">It is saved in Claude Code's settings (<span class="mono">~/.claude/settings.json</span>), so new
3918
+ Claude Code sessions can use it. Restart any open ones. This page never shows the key again.
3919
+ ${k.source === 'shell' ? ' A key set in your shell takes priority over a saved one.' : ''}</p>
3920
+ </section></div>`)}
3921
+ ${card('Where Jev fits in your work', `<div class="cfg">
3922
+ <p class="blk-intro">We looked for your Claude Code prompts that were really just a decision: short, no tools, and
3923
+ asking "which one", "yes or no", "classify" and the like.</p>
3924
+ <div class="grid g4">
3925
+ ${kpi('Decision-like prompts', fmtInt(fit.prompts), `of ${fmtInt(fit.total_prompts)} in this range`)}
3926
+ ${kpi('Cost on Claude', usd(fit.claude_cost), fmtPct(fit.share_pct, 2) + ' of your spend', {badge: BADGE.estimated})}
3927
+ ${kpi('Rough cost on Jev', usd(fit.jev_cost), 'at TypeSafe\'s published price', {badge: BADGE.estimated})}
3928
+ ${kpi('Saving', usd(Math.max(fit.claude_cost - fit.jev_cost, 0)), 'if these had gone to Jev')}
3929
+ </div>
3930
+ ${small ? `<p class="fld-hint" style="margin-top:8px">Your Claude Code work is mostly writing code, which Jev can't do.
3931
+ Jev pays off in apps and scripts that make many decisions, like sorting support tickets or reviewing pull requests.</p>` : ''}
3932
+ ${fit.examples.length ? `<div class="sec" style="margin-top:12px">Examples</div><div class="stack">${fit.examples.map(e =>
3933
+ `<div class="item"><div class="dt">${MASKED ? '<i>Prompt text hidden</i>' : esc(e.text)}
3934
+ <span class="spacer"></span> ${fmtUSD(e.cost)}</div></div>`).join('')}</div>` : ''}</div>`,
3935
+ {badge: BADGE.estimated, hint: 'read-only, from this computer\'s transcripts'})}`;
3936
+ JEV_MSG = null;
3937
+ const redraw = msg => { JEV_MSG = msg; bust(); render(); };
3938
+ const confirmBox = $('#jev-confirm', page);
3939
+ const inst = $('#jev-install', page);
3940
+ if (inst) inst.onclick = () => { confirmBox.hidden = false; $('#jev-go', page).focus(); };
3941
+ $('#jev-cancel', page).onclick = () => { confirmBox.hidden = true; };
3942
+ const runJob = async (what, btn) => {
3943
+ btn.disabled = true;
3944
+ try {
3945
+ const {job} = await doAction('jev/' + what);
3946
+ const j = await followJob(job, $('#jev-log', page));
3947
+ redraw({where: 'install', ok: j.state === 'done',
3948
+ text: j.state === 'done' ? (what === 'install' ? 'Installed. Restart any open Claude Code sessions to use it.' : 'Removed.')
3949
+ : (j.log[j.log.length - 1] || 'It did not finish.')});
3950
+ } catch (e) { redraw({where: 'install', ok: false, text: e.message}); }
3951
+ };
3952
+ $('#jev-go', page).onclick = e => runJob('install', e.currentTarget);
3953
+ const un = $('#jev-uninstall', page);
3954
+ if (un) un.onclick = () => {
3955
+ if (!un.dataset.armed) {
3956
+ un.dataset.armed = '1'; un.textContent = 'Click again to uninstall';
3957
+ setTimeout(() => { if (un.isConnected) { delete un.dataset.armed; un.textContent = 'Uninstall'; } }, 4000);
3958
+ return;
3959
+ }
3960
+ runJob('uninstall', un);
3961
+ };
3962
+ const keyIn = $('#jev-key', page), keyErr = $('#jev-key-err', page);
3963
+ keyIn.oninput = () => { keyErr.textContent = ''; keyIn.classList.remove('bad'); };
3964
+ keyIn.onkeydown = e => { if (e.key === 'Enter') $('#jev-key-save', page).click(); };
3965
+ $('#jev-key-save', page).onclick = async () => {
3966
+ if (!keyIn.value.trim()) { keyErr.textContent = 'Paste your Jev API key first.'; keyIn.classList.add('bad'); return; }
3967
+ try { await doAction('jev/key', {value: keyIn.value}); redraw({where: 'key', ok: true, text: 'Saved. New Claude Code sessions will use it.'}); }
3968
+ catch (e) { keyErr.textContent = e.message; keyIn.classList.add('bad'); }
3969
+ };
3970
+ const rm = $('#jev-key-rm', page);
3971
+ if (rm) rm.onclick = async () => {
3972
+ if (!rm.dataset.armed) {
3973
+ rm.dataset.armed = '1'; rm.textContent = 'Click again to remove';
3974
+ setTimeout(() => { if (rm.isConnected) { delete rm.dataset.armed; rm.textContent = 'Remove'; } }, 4000);
3975
+ return;
3976
+ }
3977
+ try { await doAction('jev/key', {remove: true}); redraw({where: 'key', ok: true, text: 'Removed.'}); }
3978
+ catch (e) { keyErr.textContent = e.message; }
3979
+ };
3980
+ };
3981
+
3219
3982
  VIEWS.toolkit = async (page) => {
3220
3983
  const d = await fetch('/api/suggestions').then(r => r.json());
3221
3984
  const ev = x => (x || []).map(e => `<code>${esc(e)}</code>`).join(' ');
@@ -3273,9 +4036,8 @@ VIEWS.cloud = async (page) => {
3273
4036
  <span class="note">${cfg[k] ? 'key found' : 'no key'}</span></div>
3274
4037
  <div class="dt"><b>Gives you:</b> ${esc(p.covers)}</div>
3275
4038
  <div class="dt"><b>Get a key:</b> ${esc(p.how)}</div>
3276
- <div class="dt">Then set <code>${esc(p.env)}</code> in your environment, or add
3277
- <code>"${esc(p.field)}"</code> to <code>~/.claude-finops/secrets.local.json</code>
3278
- (gitignored, never packaged). Restart the dashboard afterwards.</div>
4039
+ <div class="dt">Then add it on the <a href="#" data-go-settings>Settings</a> page,
4040
+ run <code>claude-finops --set-key</code>, or set <code>${esc(p.env)}</code> in your environment.</div>
3279
4041
  </div>`).join('');
3280
4042
  page.innerHTML = `
3281
4043
  <div class="note"><b>The only page that talks to the internet.</b> ${esc(d.note)}
@@ -3297,7 +4059,7 @@ VIEWS.cloud = async (page) => {
3297
4059
  `${fmtInt(t.cursor_members)} members`, {badge: BADGE.actual})}
3298
4060
  </div>
3299
4061
  ${card('Set up the APIs', `<div class="stack">${setup}</div>`,
3300
- {hint: 'Keys are read from your environment or a local file; the dashboard never stores them'})}
4062
+ {hint: 'Stored keys stay on this machine (0600) and are never shown again'})}
3301
4063
  ${card('Billed vs local, per day', `<div id="cl-day"></div>`, {flush: 1, badge: BADGE.actual,
3302
4064
  hint: 'Claude Code: what the vendor billed against what this machine recorded'})}
3303
4065
  ${card('Claude Code users (org-wide)', `<div id="cl-users"></div>`, {flush: 1, badge: BADGE.actual,
@@ -3305,6 +4067,7 @@ VIEWS.cloud = async (page) => {
3305
4067
  ${card('Cursor members', `<div id="cl-cur"></div>`, {flush: 1, badge: BADGE.actual})}
3306
4068
  ${card('Billed API cost by line item', `<div id="cl-api"></div>`, {flush: 1, badge: BADGE.actual,
3307
4069
  hint: 'From the Anthropic cost report: API spend only, not subscription plans'})}`;
4070
+ page.querySelectorAll('[data-go-settings]').forEach(a => a.onclick = e => { e.preventDefault(); go('settings'); });
3308
4071
  $('#cl-day', page).innerHTML = table([
3309
4072
  {h: 'Day', f: r => esc(r.day)},
3310
4073
  {h: 'Billed $', num: 1, f: r => fmtUSD(r.billed_cost)},
@@ -3495,6 +4258,10 @@ const TOURS = {
3495
4258
  {el: 'card:High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
3496
4259
  {el: 'card:Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
3497
4260
  {el: 'card:Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
4261
+ jev: [
4262
+ {el: 'card:What is Jev?', t: 'What Jev is', see: 'A fast, cheap AI that only makes decisions: pick one, yes or no, a score, a value.', get: 'Cheaper, faster apps and scripts, wherever an AI call only decides something.', act: 'Read the three lines. Jev does not make Claude Code itself cheaper.'},
4263
+ {el: 'card:Set up Jev', t: 'Install and key', see: 'One click installs the Jev plugin for Claude Code; the key is saved in Claude Code\'s settings.', get: 'Claude knows how to use Jev when you build something.', act: 'Press Install, check the two commands, then Confirm. Paste your key and Save.'},
4264
+ {el: 'card:Where Jev fits', t: 'Where Jev fits', see: 'Your prompts that were really just a decision, and what they would cost on Jev.', get: 'An honest number before you change anything.', act: 'If it is tiny, Jev belongs in your apps, not your coding sessions.'}],
3498
4265
  freemodels: [
3499
4266
  {el: 'card:How to use and test a free model', t: 'How it works', see: 'How to plug a local or free cloud model into Claude Code, and how to test it.', get: 'Zero-cost options for simple or private work.', act: 'Read the RAM guidance before you pick a model.'},
3500
4267
  {el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: `Click <b>${I('plus')} Add</b> on a model that fits your RAM.`}],
@@ -3520,10 +4287,14 @@ const TOURS = {
3520
4287
  {el: 'card:Scenarios', t: 'Scenarios', see: 'Optimistic, expected and pessimistic end-of-period totals.', get: 'A best and worst case to plan against.', act: 'Budget against the pessimistic number.'}],
3521
4288
  budgets: [
3522
4289
  {el: 'card:Budget vs actual vs forecast', t: 'Budget vs actual', see: 'Each budget line with its budget, actual, forecast and variance.', get: 'A warning before you overspend, not after.', act: 'Watch the variance column: a positive forecast variance means trouble.'},
3523
- {el: 'card:Configure budgets', t: 'Configure budgets', see: 'Your budget lines, limits and alert thresholds.', get: 'Numbers that make the forecast and burn dashboards meaningful.', act: 'Edit a budget and save; every dashboard picks it up.'}],
4290
+ {el: 'card:Set your limits', t: 'Configure budgets', see: 'Your budget lines, limits and alert thresholds.', get: 'Numbers that make the forecast and burn dashboards meaningful.', act: 'Edit a budget and save; every dashboard picks it up.'},
4291
+ {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.'},
4292
+ {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.'}],
4293
+ settings: [
4294
+ {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.'}],
3524
4295
  cloud: [
3525
4296
  {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.`},
3526
- {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: 'Run claude-finops --set-key, or set the environment variable, then restart.'},
4297
+ {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.'},
3527
4298
  {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.'},
3528
4299
  {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.'},
3529
4300
  {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.'},