claude-finops 0.1.0 → 0.2.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/finops/api.py CHANGED
@@ -177,7 +177,7 @@ class Handler(BaseHTTPRequestHandler):
177
177
  return self.send_json({"error": "unknown route"}, 404)
178
178
 
179
179
  def session_action(self, path):
180
- """POST /api/live/<pid>/<interrupt|close|kill>.
180
+ """POST /api/live/<pid>/<interrupt|close|kill|compact|handover>.
181
181
 
182
182
  Guarded against cross-site requests: a custom header can't be sent by another
183
183
  origin without a CORS preflight (which this server never approves), and any
@@ -195,12 +195,16 @@ class Handler(BaseHTTPRequestHandler):
195
195
  agent = (self._payload or {}).get("agent") or "claude"
196
196
  if agent != "claude":
197
197
  from .procs import act_agent
198
- if parts[3] == "handover":
199
- return self.send_json({"ok": False, "error": "Hand over is only available for Claude Code."})
198
+ if parts[3] in ("handover", "compact"):
199
+ return self.send_json({"ok": False,
200
+ "error": f"{parts[3].capitalize()} is only available for Claude Code."})
200
201
  return self.send_json(act_agent(int(parts[2]), parts[3], agent))
201
202
  if parts[3] == "handover":
202
203
  from .procs import handover
203
204
  return self.send_json(handover(int(parts[2]), self._payload))
205
+ if parts[3] == "compact":
206
+ from .procs import compact
207
+ return self.send_json(compact(int(parts[2]), self._payload))
204
208
  return self.send_json(act(int(parts[2]), parts[3]))
205
209
 
206
210
  def do_GET(self):
@@ -222,6 +226,9 @@ class Handler(BaseHTTPRequestHandler):
222
226
  self.send_json({"error": traceback.format_exc()}, 500)
223
227
 
224
228
  def api(self, route, qs):
229
+ if route == "usage":
230
+ from .limits import usage
231
+ return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
225
232
  if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
226
233
  return self.actions_get(route)
227
234
  f = filters_from(qs)
@@ -0,0 +1,105 @@
1
+ """Current plan usage, as `/usage` reports it.
2
+
3
+ Claude Code owns those numbers (5-hour session window and weekly windows); they
4
+ are not in any file on this machine. The one way to read them without an account
5
+ API is to ask Claude Code itself:
6
+
7
+ claude -p "/usage" --output-format json
8
+
9
+ That runs the local slash command, spends no tokens and makes no model call, and
10
+ returns the same text `/usage` prints in the TUI. The reply is parsed here and
11
+ cached briefly so the dashboard never shells out on every render.
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+
20
+ from .paths import DATA_DIR, ensure_dirs
21
+
22
+ CACHE = os.path.join(DATA_DIR, "usage_cache.json")
23
+ TTL_S = 120
24
+ TIMEOUT_S = 90
25
+
26
+ # "Current session: 0% used · resets Sep 22 at 2:40pm (Asia/Calcutta)"
27
+ _LIMIT = re.compile(r"^(?P<label>[^:]+?):\s*(?P<pct>\d+(?:\.\d+)?)%\s*used"
28
+ r"(?:\s*[·-]\s*resets\s*(?P<resets>.+?))?\s*$")
29
+ # " 40% of your usage was at >150k context" / " Top skills: /run 2%"
30
+ _WINDOW = re.compile(r"^Last\s+(?P<window>\S+)\s*·\s*(?P<detail>.+)$")
31
+
32
+
33
+ def _parse(text):
34
+ limits, windows, cur = [], [], None
35
+ for raw in text.splitlines():
36
+ line = raw.strip()
37
+ if not line:
38
+ continue
39
+ m = _WINDOW.match(line)
40
+ if m:
41
+ cur = {"window": m.group("window"), "detail": m.group("detail"), "notes": []}
42
+ windows.append(cur)
43
+ continue
44
+ m = _LIMIT.match(line)
45
+ if m and line.lower().startswith("current"):
46
+ limits.append({"label": m.group("label").strip(),
47
+ "pct": float(m.group("pct")),
48
+ "resets": (m.group("resets") or "").strip()})
49
+ cur = None
50
+ continue
51
+ if cur is not None and raw.startswith((" ", "\t")):
52
+ cur["notes"].append(line)
53
+ return limits, windows
54
+
55
+
56
+ def _run():
57
+ exe = shutil.which("claude")
58
+ if not exe:
59
+ return {"ok": False, "error": "`claude` is not on PATH, so /usage can't be read here."}
60
+ try:
61
+ p = subprocess.run([exe, "-p", "/usage", "--output-format", "json"],
62
+ capture_output=True, text=True, timeout=TIMEOUT_S,
63
+ cwd=os.path.expanduser("~"))
64
+ except subprocess.TimeoutExpired:
65
+ return {"ok": False, "error": f"`claude -p /usage` did not answer within {TIMEOUT_S}s."}
66
+ except OSError as e:
67
+ return {"ok": False, "error": f"Could not run `claude`: {e}"}
68
+ if p.returncode != 0:
69
+ return {"ok": False, "error": (p.stderr or p.stdout or "claude exited non-zero").strip()[:400]}
70
+ try:
71
+ text = (json.loads(p.stdout) or {}).get("result") or ""
72
+ except ValueError:
73
+ return {"ok": False, "error": "Could not parse the reply from `claude -p /usage`."}
74
+ if not text.strip():
75
+ return {"ok": False, "error": "`/usage` returned nothing."}
76
+ limits, windows = _parse(text)
77
+ return {"ok": True, "limits": limits, "windows": windows, "text": text,
78
+ "fetched_at": time.time(),
79
+ "note": "Read from Claude Code's own /usage. Percentages are plan limits, not cost; "
80
+ "the contributing-behaviour lines cover local sessions on this machine only."}
81
+
82
+
83
+ def usage(force=False):
84
+ """Cached /usage. Reads the cache unless it is stale or `force` is set."""
85
+ ensure_dirs()
86
+ cached = None
87
+ try:
88
+ with open(CACHE) as fh:
89
+ cached = json.load(fh)
90
+ except (OSError, ValueError):
91
+ pass
92
+ if cached and cached.get("ok") and not force and time.time() - cached.get("fetched_at", 0) < TTL_S:
93
+ return dict(cached, cached=True, age_s=int(time.time() - cached["fetched_at"]))
94
+ res = _run()
95
+ if res.get("ok"):
96
+ try:
97
+ with open(CACHE, "w") as fh:
98
+ json.dump(res, fh)
99
+ except OSError:
100
+ pass
101
+ return dict(res, cached=False, age_s=0)
102
+ if cached and cached.get("ok"): # keep showing the last good read
103
+ return dict(cached, cached=True, stale=True, error=res.get("error"),
104
+ age_s=int(time.time() - cached["fetched_at"]))
105
+ return res
package/finops/procs.py CHANGED
@@ -497,3 +497,163 @@ def handover(pid, opts):
497
497
  res["closed"] = act(pid, "close")
498
498
  res["message"] += " Old session closed (resumable)."
499
499
  return res
500
+
501
+
502
+ # ------------------------------------------------------------------ compact ----
503
+ # /compact is typed *into* the running session — there is no signal for it. Nothing
504
+ # can write to another process's terminal directly (TIOCSTI is off on macOS and on
505
+ # modern Linux), so the keystrokes are handed to whatever owns that terminal:
506
+ # tmux if the session runs inside one, else Terminal.app or iTerm2 via
507
+ # AppleScript, else Windows Terminal/console via SendKeys. When none of those owns
508
+ # it, the UI falls back to copying the command for you to paste.
509
+
510
+ def _tty_of(pid):
511
+ """Controlling terminal of a pid, as a device path ('/dev/ttys004')."""
512
+ if os.name == "nt":
513
+ return ""
514
+ try:
515
+ t = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)],
516
+ capture_output=True, text=True, timeout=10).stdout.strip()
517
+ except (OSError, subprocess.TimeoutExpired):
518
+ return ""
519
+ if not t or t in ("?", "??", "-"):
520
+ return ""
521
+ return t if t.startswith("/dev/") else "/dev/" + t
522
+
523
+
524
+ def _send_tmux(tty, pid, text):
525
+ if not shutil.which("tmux"):
526
+ return False, ""
527
+ try:
528
+ out = subprocess.run(["tmux", "list-panes", "-a", "-F",
529
+ "#{pane_tty}\t#{pane_pid}\t#{session_name}:#{window_index}.#{pane_index}"],
530
+ capture_output=True, text=True, timeout=10)
531
+ except (OSError, subprocess.TimeoutExpired):
532
+ return False, ""
533
+ if out.returncode != 0:
534
+ return False, ""
535
+ procs = _ps()
536
+ for line in out.stdout.splitlines():
537
+ parts = line.split("\t")
538
+ if len(parts) != 3:
539
+ continue
540
+ pane_tty, pane_pid, target = parts
541
+ if pane_tty != tty and not (pane_pid.isdigit() and int(pane_pid) in _ancestors(pid, procs)):
542
+ continue
543
+ r = subprocess.run(["tmux", "send-keys", "-t", target, "--", text],
544
+ capture_output=True, text=True)
545
+ if r.returncode:
546
+ return False, (r.stderr or "").strip()
547
+ subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], capture_output=True)
548
+ return True, "tmux"
549
+ return False, ""
550
+
551
+
552
+ def _send_macos(tty, text):
553
+ """Terminal.app / iTerm2 both expose each tab's tty, so the right one is addressable."""
554
+ if sys.platform != "darwin" or not tty:
555
+ return False, ""
556
+ t = json.dumps(text)
557
+ dev = json.dumps(tty)
558
+ osa_terminal = f'''
559
+ tell application "System Events" to set running_ to (exists process "Terminal")
560
+ if running_ then
561
+ tell application "Terminal"
562
+ repeat with w in windows
563
+ repeat with tb in tabs of w
564
+ if (tty of tb) is {dev} then
565
+ do script {t} in tb
566
+ return "ok"
567
+ end if
568
+ end repeat
569
+ end repeat
570
+ end tell
571
+ end if
572
+ return "no"'''
573
+ osa_iterm = f'''
574
+ tell application "System Events" to set running_ to (exists process "iTerm2")
575
+ if running_ then
576
+ tell application "iTerm2"
577
+ repeat with w in windows
578
+ repeat with tb in tabs of w
579
+ repeat with s in sessions of tb
580
+ if (tty of s) is {dev} then
581
+ tell s to write text {t}
582
+ return "ok"
583
+ end if
584
+ end repeat
585
+ end repeat
586
+ end repeat
587
+ end tell
588
+ end if
589
+ return "no"'''
590
+ for script, who in ((osa_terminal, "Terminal.app"), (osa_iterm, "iTerm2")):
591
+ try:
592
+ r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=25)
593
+ except (OSError, subprocess.TimeoutExpired):
594
+ continue
595
+ if r.returncode == 0 and r.stdout.strip() == "ok":
596
+ return True, who
597
+ if r.returncode and "not allowed" in (r.stderr or "").lower():
598
+ return False, ("macOS blocked the keystroke: allow this terminal under System Settings → "
599
+ "Privacy & Security → Automation.")
600
+ return False, ""
601
+
602
+
603
+ def _send_windows(pid, text):
604
+ """Windows: focus the session's console window, then SendKeys into it."""
605
+ if os.name != "nt":
606
+ return False, ""
607
+ ps = ('$ErrorActionPreference="Stop";'
608
+ 'Add-Type -AssemblyName Microsoft.VisualBasic;'
609
+ 'Add-Type -AssemblyName System.Windows.Forms;'
610
+ f'$p=Get-Process -Id {pid};'
611
+ '$h=$p.MainWindowHandle;'
612
+ 'while($h -eq 0 -and $p.Parent){$p=$p.Parent;$h=$p.MainWindowHandle};'
613
+ 'if($h -eq 0){"no";exit};'
614
+ '[Microsoft.VisualBasic.Interaction]::AppActivate($p.Id);'
615
+ 'Start-Sleep -Milliseconds 300;'
616
+ f'[System.Windows.Forms.SendKeys]::SendWait({json.dumps(text)}+"{{ENTER}}");"ok"')
617
+ try:
618
+ r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
619
+ capture_output=True, text=True, timeout=30)
620
+ except (OSError, subprocess.TimeoutExpired):
621
+ return False, ""
622
+ return (r.returncode == 0 and r.stdout.strip().endswith("ok")), ""
623
+
624
+
625
+ def compact(pid, opts=None):
626
+ """Type `/compact` into a running Claude Code session's terminal.
627
+
628
+ opts: instructions – optional focus for the summary ("/compact <instructions>")
629
+ """
630
+ opts = opts or {}
631
+ extra = " ".join((opts.get("instructions") or "").split())[:400]
632
+ text = "/compact" + (f" {extra}" if extra else "")
633
+ try:
634
+ with open(os.path.join(REG, f"{pid}.json")) as fh:
635
+ reg = json.load(fh)
636
+ except (OSError, ValueError):
637
+ return {"ok": False, "error": "Not a registered Claude Code session.", "copy": text}
638
+ p = _ps().get(pid)
639
+ if not p or not _is_claude(p):
640
+ return {"ok": False, "error": "Process is no longer running.", "copy": text}
641
+ if reg.get("procStart") and reg["procStart"] != p["lstart"]:
642
+ return {"ok": False, "error": "PID was reused by another process; refusing.", "copy": text}
643
+
644
+ tty = _tty_of(pid)
645
+ hint = ""
646
+ for send in (lambda: _send_tmux(tty, pid, text),
647
+ lambda: _send_macos(tty, text),
648
+ lambda: _send_windows(pid, text)):
649
+ ok, info = send()
650
+ if ok:
651
+ return {"ok": True, "message": f"Sent `{text}` to the session"
652
+ f"{f' via {info}' if info else ''}. "
653
+ "It compacts on its next turn; watch that window.",
654
+ "sent": True, "copy": text}
655
+ if info and not hint:
656
+ hint = info
657
+ return {"ok": False, "sent": False, "copy": text,
658
+ "error": (hint or "Couldn't reach that session's terminal") +
659
+ f" — `{text}` is on your clipboard; paste it in that window."}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/web/app.js CHANGED
@@ -1510,13 +1510,37 @@ async function sessionAction(pid, action, body = {}) {
1510
1510
  headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
1511
1511
  return r.json();
1512
1512
  }
1513
+ // Plan limits as `/usage` reports them: asked of Claude Code itself, server-side.
1514
+ const usageSev = p => p >= 90 ? 'critical' : p >= 75 ? 'approaching' : p >= 50 ? 'high' : 'healthy';
1515
+ const usageBody = u => {
1516
+ if (!u || !u.ok) return `<div class="empty">${esc((u && u.error) || 'Usage is unavailable')}</div>`;
1517
+ const meters = (u.limits || []).map(l => `
1518
+ <div class="item">
1519
+ <div class="hd"><b>${esc(l.label)}</b><span class="spacer"></span>
1520
+ <span style="font-variant-numeric:tabular-nums">${fmtPct(l.pct)} used</span></div>
1521
+ <div class="meter ${usageSev(l.pct)}"><i style="width:${Math.min(100, l.pct)}%"></i></div>
1522
+ ${l.resets ? `<div class="dt note">Resets ${esc(l.resets)}</div>` : ''}</div>`).join('')
1523
+ || `<pre class="note">${esc(u.text || '')}</pre>`;
1524
+ const windows = (u.windows || []).map(w => `
1525
+ <div class="item"><div class="hd">Last ${esc(w.window)}<span class="spacer"></span>
1526
+ <span class="note">${esc(w.detail)}</span></div>
1527
+ ${(w.notes || []).map(n => `<div class="dt">• ${esc(n)}</div>`).join('')}</div>`).join('');
1528
+ return `<div class="stack">${meters}${windows}</div>
1529
+ <div class="note">${esc(u.note || '')}${u.cached ? ` · read ${dur(u.age_s)} ago` : ''}${u.stale
1530
+ ? ` · <b>last good read</b> (refresh failed: ${esc(u.error || '')})` : ''}</div>`;
1531
+ };
1513
1532
  VIEWS.live = async (page) => {
1514
- const res = await fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()); // never cached: always live
1533
+ const [res, usage0] = await Promise.all([
1534
+ fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
1535
+ fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
1515
1536
  const list = res.sessions || [];
1516
1537
  const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
1517
1538
  const busy = list.filter(x => x.status === 'busy');
1518
1539
  const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
1519
1540
  page.innerHTML = `
1541
+ ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
1542
+ {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
1543
+ actions: '<button class="btn pb-copy" id="usage-refresh">↻ Refresh usage</button>'})}
1520
1544
  <div class="grid g4">
1521
1545
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
1522
1546
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
@@ -1525,6 +1549,8 @@ VIEWS.live = async (page) => {
1525
1549
  </div>
1526
1550
  <div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
1527
1551
  which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
1552
+ <b>Compact</b> types <code>/compact</code> into that session's terminal (tmux, Terminal.app, iTerm2 or
1553
+ Windows console; anywhere else it lands on your clipboard to paste).
1528
1554
  <b>Close</b> exits the session cleanly; copy its resume command to bring it back.
1529
1555
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
1530
1556
  Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
@@ -1543,9 +1569,10 @@ VIEWS.live = async (page) => {
1543
1569
  <span class="note">${esc(x.cwd)}</span></div>
1544
1570
  ${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
1545
1571
  ? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
1546
- : 'Getting heavy. /compact at the next break, or close it if the task is done.'}</div>` : ''}
1572
+ : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
1547
1573
  <div class="live-actions">
1548
1574
  ${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>
1575
+ ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
1549
1576
  <button class="act warn" data-a="close">⏹ Close session</button>
1550
1577
  <button class="act danger" data-a="kill">✖ 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>`}
1551
1578
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
@@ -1566,6 +1593,15 @@ VIEWS.live = async (page) => {
1566
1593
  </div>`}
1567
1594
  </div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
1568
1595
  $('#live-refresh', page).onclick = () => render();
1596
+ const ub = $('#usage-refresh', page);
1597
+ ub.onclick = async () => {
1598
+ ub.disabled = true; ub.textContent = 'Reading…';
1599
+ try {
1600
+ const u = await fetch('/api/usage?refresh=1').then(r => r.json());
1601
+ $('#live-usage', page).innerHTML = usageBody(u);
1602
+ } catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
1603
+ ub.disabled = false; ub.textContent = '↻ Refresh usage';
1604
+ };
1569
1605
  page.querySelectorAll('.item[data-i]').forEach(row => {
1570
1606
  const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
1571
1607
  const ho = row.querySelector('.handover'), ta = ho?.querySelector('textarea'), hoOut = ho?.querySelector('.ho-out');
@@ -1597,7 +1633,7 @@ VIEWS.live = async (page) => {
1597
1633
  if (!b.classList.contains('armed')) {
1598
1634
  row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
1599
1635
  b.dataset.label = label; b.classList.add('armed');
1600
- b.textContent = x.hosts_dashboard && b.dataset.a !== 'interrupt'
1636
+ b.textContent = x.hosts_dashboard && !['interrupt', 'compact'].includes(b.dataset.a)
1601
1637
  ? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)' : `Click again to ${b.dataset.a}`;
1602
1638
  setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
1603
1639
  return;
@@ -1605,6 +1641,7 @@ VIEWS.live = async (page) => {
1605
1641
  b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
1606
1642
  try {
1607
1643
  const r = await sessionAction(pid, b.dataset.a, {agent: x.agent});
1644
+ if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
1608
1645
  msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
1609
1646
  msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
1610
1647
  if (r.ok && r.exited) { row.classList.add('gone'); setTimeout(() => render(), 1500); }