claude-finops 0.1.0 → 0.3.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/README.md CHANGED
@@ -290,8 +290,15 @@ place for you to delete once you are happy.
290
290
  ## Privacy
291
291
 
292
292
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
293
- server binds to `127.0.0.1` only and makes no outbound requests, but treat the
294
- database and any export you generate as sensitive.
293
+ server binds to `127.0.0.1` only, but treat the database and any export you
294
+ generate as sensitive.
295
+
296
+ The app makes exactly one outbound request of its own: once a day it asks
297
+ `registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
298
+ you when an upgrade is out (npm has no way to push one at you). It sends nothing
299
+ about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
300
+ `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
301
+ configure a key with `--set-key`.
295
302
 
296
303
  ---
297
304
 
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,12 @@ 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 == "update":
230
+ from .update import check
231
+ return self.send_json(check(force=qs.get("refresh", [""])[0] == "1"))
232
+ if route == "usage":
233
+ from .limits import usage
234
+ return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
225
235
  if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
226
236
  return self.actions_get(route)
227
237
  f = filters_from(qs)
@@ -399,6 +409,11 @@ def _sync_job(log):
399
409
  return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
400
410
 
401
411
 
412
+ def _notify_update():
413
+ from .update import notify
414
+ notify()
415
+
416
+
402
417
  def serve(port=8787, db=DB_PATH, background=None):
403
418
  global A
404
419
  if not os.path.exists(db):
@@ -413,6 +428,8 @@ def serve(port=8787, db=DB_PATH, background=None):
413
428
  print(f" warehouse: {db}")
414
429
  print(f" data : {A.first_day} .. {A.last_day}")
415
430
  print(" Ctrl-C to stop.")
431
+ # Off the main thread: a slow registry must never delay the dashboard.
432
+ threading.Thread(target=_notify_update, daemon=True).start()
416
433
  try:
417
434
  srv.serve_forever()
418
435
  except KeyboardInterrupt:
@@ -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."}
@@ -0,0 +1,133 @@
1
+ """Is there a newer release on npm?
2
+
3
+ npm has no way to push: a global install stays on whatever version it was
4
+ installed at until someone runs `npm i -g claude-finops@latest`. So we ask the
5
+ registry ourselves, once a day, and say so if there is something newer.
6
+
7
+ This is the only outbound request the app makes on its own. It sends nothing
8
+ about you — no identifiers, no usage, not even a User-Agent beyond the package
9
+ name and the version you already published to npm by installing it. Turn it off
10
+ with NO_UPDATE_NOTIFIER=1 (the ecosystem-wide convention) or
11
+ CLAUDE_FINOPS_NO_UPDATE_CHECK=1.
12
+
13
+ The answer is cached in ~/.claude-finops/data/update_cache.json for a day, so a
14
+ dashboard that restarts twenty times makes one request. Every failure is
15
+ silent: no network, a proxy, an offline laptop and a 500 from the registry all
16
+ look the same to the caller, which is "we don't know", never an error.
17
+ """
18
+ import json
19
+ import os
20
+ import re
21
+ import time
22
+ import urllib.request
23
+
24
+ from .paths import DATA_DIR, ROOT, ensure_dirs
25
+
26
+ CACHE = os.path.join(DATA_DIR, "update_cache.json")
27
+ URL = "https://registry.npmjs.org/claude-finops/latest"
28
+ TTL_S = 24 * 60 * 60
29
+ TIMEOUT_S = 3
30
+
31
+ _NUM = re.compile(r"\d+")
32
+
33
+
34
+ def _installed():
35
+ """Our own version, from the package.json we ship next to the code."""
36
+ try:
37
+ with open(os.path.join(ROOT, "package.json")) as fh:
38
+ return str(json.load(fh).get("version") or "").strip()
39
+ except (OSError, ValueError):
40
+ return ""
41
+
42
+
43
+ def _key(v):
44
+ """Compare 1.10.0 above 1.9.0, and treat 1.0.0-rc.1 as below 1.0.0.
45
+
46
+ Only the numeric core is ordered; a prerelease suffix just loses the tie.
47
+ That is enough for a notifier — we publish plain releases.
48
+ """
49
+ core, _, pre = str(v).partition("-")
50
+ nums = [int(n) for n in _NUM.findall(core)[:3]]
51
+ return (nums + [0, 0, 0])[:3], 0 if pre else 1
52
+
53
+
54
+ def _newer(latest, current):
55
+ return bool(latest) and bool(current) and _key(latest) > _key(current)
56
+
57
+
58
+ def _read_cache():
59
+ try:
60
+ with open(CACHE) as fh:
61
+ c = json.load(fh)
62
+ return c if isinstance(c, dict) else {}
63
+ except (OSError, ValueError):
64
+ return {}
65
+
66
+
67
+ def _write_cache(latest):
68
+ ensure_dirs()
69
+ tmp = CACHE + ".tmp"
70
+ try:
71
+ with open(tmp, "w") as fh:
72
+ json.dump({"latest": latest, "checked_at": int(time.time())}, fh)
73
+ os.replace(tmp, CACHE)
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def _fetch():
79
+ req = urllib.request.Request(URL, headers={
80
+ "Accept": "application/json",
81
+ "User-Agent": f"claude-finops/{_installed() or '0'}",
82
+ })
83
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r:
84
+ return str(json.load(r).get("version") or "").strip()
85
+
86
+
87
+ def disabled():
88
+ return bool(os.environ.get("NO_UPDATE_NOTIFIER")
89
+ or os.environ.get("CLAUDE_FINOPS_NO_UPDATE_CHECK"))
90
+
91
+
92
+ def check(force=False):
93
+ """Return {current, latest, update_available, checked_at, ...}.
94
+
95
+ Reads the cache unless it is older than a day (or `force`). Never raises,
96
+ and never blocks longer than TIMEOUT_S.
97
+ """
98
+ current = _installed()
99
+ out = {"current": current, "latest": "", "update_available": False,
100
+ "command": "npm i -g claude-finops@latest", "checked_at": 0,
101
+ "disabled": disabled()}
102
+ if out["disabled"]:
103
+ return out
104
+
105
+ cache = _read_cache()
106
+ age = time.time() - float(cache.get("checked_at") or 0)
107
+ if cache.get("latest") and age < TTL_S and not force:
108
+ out.update(latest=cache["latest"], checked_at=int(cache["checked_at"]), cached=True)
109
+ else:
110
+ try:
111
+ latest = _fetch()
112
+ except Exception:
113
+ # Offline, blocked, rate-limited — fall back to the last good answer
114
+ # rather than telling anyone anything is wrong.
115
+ latest = str(cache.get("latest") or "")
116
+ out["checked_at"] = int(cache.get("checked_at") or 0)
117
+ out["stale"] = True
118
+ else:
119
+ _write_cache(latest)
120
+ out["checked_at"] = int(time.time())
121
+ out["latest"] = latest
122
+ out["update_available"] = _newer(out["latest"], current)
123
+ return out
124
+
125
+
126
+ def notify(log=print):
127
+ """Print one line if a newer version is out. Safe to call from a thread."""
128
+ try:
129
+ u = check()
130
+ except Exception:
131
+ return
132
+ if u.get("update_available"):
133
+ log(f" update : {u['current']} -> {u['latest']} {u['command']}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.1.0",
3
+ "version": "0.3.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/run.py CHANGED
@@ -100,6 +100,7 @@ Environment:
100
100
  CLAUDE_FINOPS_HOME=/path where your data lives (default ~/.claude-finops)
101
101
  CLAUDE_PROJECTS=/path where to read transcripts from
102
102
  CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
103
+ NO_UPDATE_NOTIFIER=1 never check npm for a newer release
103
104
  """
104
105
 
105
106
 
package/web/app.js CHANGED
@@ -160,6 +160,7 @@ function shell() {
160
160
  <span class="spacer"></span>
161
161
  <div class="search"><span class="mag">⌕</span>
162
162
  <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
163
+ <button class="iconbtn upd" id="upd" hidden></button>
163
164
  <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
164
165
  <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
165
166
  <button class="iconbtn" id="refresh" title="Reload data">↻</button>
@@ -182,6 +183,7 @@ function shell() {
182
183
  render();
183
184
  };
184
185
  $('#refresh').onclick = () => { bust(); render(); };
186
+ updateChip();
185
187
  $('#sync').onclick = runSync;
186
188
  syncLabel();
187
189
  let t;
@@ -190,6 +192,28 @@ function shell() {
190
192
  else if (S.view === 'search') go('overview'); }, 260); };
191
193
  }
192
194
 
195
+ /* ---------- update notice ----------
196
+ npm cannot push a new release at anyone, so the server asks the registry once
197
+ a day and we surface the answer here. Silent when you are current, when the
198
+ check is switched off, and when it simply could not reach the registry. */
199
+ async function updateChip() {
200
+ const el = $('#upd');
201
+ if (!el) return;
202
+ let u;
203
+ try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
204
+ if (!u || !u.update_available) return;
205
+ el.hidden = false;
206
+ el.textContent = `↑ v${u.latest} available`;
207
+ el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
208
+ el.onclick = async () => {
209
+ try {
210
+ await navigator.clipboard.writeText(u.command);
211
+ el.textContent = '✓ command copied';
212
+ setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
213
+ } catch { prompt('Run this to upgrade:', u.command); }
214
+ };
215
+ }
216
+
193
217
  /* ---------- filter bar ---------- */
194
218
  const RANGES = [['today', 'Today'], ['7d', '7 days'], ['14d', '14 days'], ['30d', '30 days'],
195
219
  ['period', 'Billing period'], ['all', 'All time'], ['custom', 'Custom']];
@@ -1510,13 +1534,37 @@ async function sessionAction(pid, action, body = {}) {
1510
1534
  headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
1511
1535
  return r.json();
1512
1536
  }
1537
+ // Plan limits as `/usage` reports them: asked of Claude Code itself, server-side.
1538
+ const usageSev = p => p >= 90 ? 'critical' : p >= 75 ? 'approaching' : p >= 50 ? 'high' : 'healthy';
1539
+ const usageBody = u => {
1540
+ if (!u || !u.ok) return `<div class="empty">${esc((u && u.error) || 'Usage is unavailable')}</div>`;
1541
+ const meters = (u.limits || []).map(l => `
1542
+ <div class="item">
1543
+ <div class="hd"><b>${esc(l.label)}</b><span class="spacer"></span>
1544
+ <span style="font-variant-numeric:tabular-nums">${fmtPct(l.pct)} used</span></div>
1545
+ <div class="meter ${usageSev(l.pct)}"><i style="width:${Math.min(100, l.pct)}%"></i></div>
1546
+ ${l.resets ? `<div class="dt note">Resets ${esc(l.resets)}</div>` : ''}</div>`).join('')
1547
+ || `<pre class="note">${esc(u.text || '')}</pre>`;
1548
+ const windows = (u.windows || []).map(w => `
1549
+ <div class="item"><div class="hd">Last ${esc(w.window)}<span class="spacer"></span>
1550
+ <span class="note">${esc(w.detail)}</span></div>
1551
+ ${(w.notes || []).map(n => `<div class="dt">• ${esc(n)}</div>`).join('')}</div>`).join('');
1552
+ return `<div class="stack">${meters}${windows}</div>
1553
+ <div class="note">${esc(u.note || '')}${u.cached ? ` · read ${dur(u.age_s)} ago` : ''}${u.stale
1554
+ ? ` · <b>last good read</b> (refresh failed: ${esc(u.error || '')})` : ''}</div>`;
1555
+ };
1513
1556
  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
1557
+ const [res, usage0] = await Promise.all([
1558
+ fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
1559
+ fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
1515
1560
  const list = res.sessions || [];
1516
1561
  const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
1517
1562
  const busy = list.filter(x => x.status === 'busy');
1518
1563
  const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
1519
1564
  page.innerHTML = `
1565
+ ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
1566
+ {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
1567
+ actions: '<button class="btn pb-copy" id="usage-refresh">↻ Refresh usage</button>'})}
1520
1568
  <div class="grid g4">
1521
1569
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
1522
1570
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
@@ -1525,6 +1573,8 @@ VIEWS.live = async (page) => {
1525
1573
  </div>
1526
1574
  <div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
1527
1575
  which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
1576
+ <b>Compact</b> types <code>/compact</code> into that session's terminal (tmux, Terminal.app, iTerm2 or
1577
+ Windows console; anywhere else it lands on your clipboard to paste).
1528
1578
  <b>Close</b> exits the session cleanly; copy its resume command to bring it back.
1529
1579
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
1530
1580
  Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
@@ -1543,9 +1593,10 @@ VIEWS.live = async (page) => {
1543
1593
  <span class="note">${esc(x.cwd)}</span></div>
1544
1594
  ${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
1545
1595
  ? '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>` : ''}
1596
+ : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
1547
1597
  <div class="live-actions">
1548
1598
  ${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>
1599
+ ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
1549
1600
  <button class="act warn" data-a="close">⏹ Close session</button>
1550
1601
  <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
1602
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
@@ -1566,6 +1617,15 @@ VIEWS.live = async (page) => {
1566
1617
  </div>`}
1567
1618
  </div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
1568
1619
  $('#live-refresh', page).onclick = () => render();
1620
+ const ub = $('#usage-refresh', page);
1621
+ ub.onclick = async () => {
1622
+ ub.disabled = true; ub.textContent = 'Reading…';
1623
+ try {
1624
+ const u = await fetch('/api/usage?refresh=1').then(r => r.json());
1625
+ $('#live-usage', page).innerHTML = usageBody(u);
1626
+ } catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
1627
+ ub.disabled = false; ub.textContent = '↻ Refresh usage';
1628
+ };
1569
1629
  page.querySelectorAll('.item[data-i]').forEach(row => {
1570
1630
  const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
1571
1631
  const ho = row.querySelector('.handover'), ta = ho?.querySelector('textarea'), hoOut = ho?.querySelector('.ho-out');
@@ -1597,7 +1657,7 @@ VIEWS.live = async (page) => {
1597
1657
  if (!b.classList.contains('armed')) {
1598
1658
  row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
1599
1659
  b.dataset.label = label; b.classList.add('armed');
1600
- b.textContent = x.hosts_dashboard && b.dataset.a !== 'interrupt'
1660
+ b.textContent = x.hosts_dashboard && !['interrupt', 'compact'].includes(b.dataset.a)
1601
1661
  ? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)' : `Click again to ${b.dataset.a}`;
1602
1662
  setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
1603
1663
  return;
@@ -1605,6 +1665,7 @@ VIEWS.live = async (page) => {
1605
1665
  b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
1606
1666
  try {
1607
1667
  const r = await sessionAction(pid, b.dataset.a, {agent: x.agent});
1668
+ if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
1608
1669
  msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
1609
1670
  msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
1610
1671
  if (r.ok && r.exited) { row.classList.add('gone'); setTimeout(() => render(), 1500); }
package/web/styles.css CHANGED
@@ -385,3 +385,13 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
385
385
  #tour .tour-ft .spacer { flex: 1; }
386
386
  #tour .act.primary { background: var(--accent, #eb6834); border-color: var(--accent, #eb6834); color: #fff; }
387
387
  #tour .act[disabled] { opacity: .45; cursor: default; }
388
+
389
+ /* Update notice in the topbar: present, never alarming — this is good news,
390
+ not a problem, so it borrows the accent colour rather than the warning one. */
391
+ .iconbtn.upd {
392
+ border-color: var(--accent, #eb6834);
393
+ color: var(--accent, #eb6834);
394
+ font-weight: 600;
395
+ white-space: nowrap;
396
+ }
397
+ .iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }