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 +10 -3
- package/finops/limits.py +105 -0
- package/finops/procs.py +160 -0
- package/package.json +1 -1
- package/web/app.js +40 -3
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]
|
|
199
|
-
return self.send_json({"ok": False,
|
|
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)
|
package/finops/limits.py
ADDED
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
|
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); }
|