claude-finops 0.15.0 → 0.16.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 +11 -5
- package/finops/api.py +8 -2
- package/finops/procs.py +118 -8
- package/package.json +1 -1
- package/web/app.js +54 -4
- package/web/styles.css +2 -0
package/README.md
CHANGED
|
@@ -438,18 +438,24 @@ files on Windows.
|
|
|
438
438
|
"Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
|
|
439
439
|
The strongest skill suggestions also appear in **⚡ Act now** on the overview.
|
|
440
440
|
- **Compact** (Running sessions, Recent, Act now, session rows) types `/compact` into that session's
|
|
441
|
-
terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
441
|
+
terminal: tmux, herdr, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
442
442
|
command for you to paste. It asks you to click twice.
|
|
443
443
|
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
444
|
-
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals
|
|
445
|
-
you list them; it can close
|
|
444
|
+
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals (a new herdr tab in the
|
|
445
|
+
same workspace when the session runs in herdr), one per sub-task if you list them; it can close
|
|
446
|
+
the old session (resumable).
|
|
447
|
+
- **Prompts** (Running sessions) shows a session's first prompt and its latest five, so you can
|
|
448
|
+
tell which session is which before you close it.
|
|
449
|
+
- **Rename** (Running sessions) types `/rename <name>` into that session's terminal, so Claude
|
|
450
|
+
Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
|
|
451
|
+
type, the command lands on your clipboard.
|
|
446
452
|
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
447
453
|
with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
|
|
448
454
|
list its prompts, and click a prompt for its full details. Search matches your prompt text.
|
|
449
455
|
It covers all dates but keeps the agent and project filters. Close and Force kill stay on
|
|
450
456
|
Running sessions.
|
|
451
|
-
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
|
|
452
|
-
Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
457
|
+
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
|
|
458
|
+
(in herdr, Interrupt presses Esc in the session's pane instead). Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
453
459
|
interrupt one console process without hitting the others in its window.
|
|
454
460
|
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
455
461
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
package/finops/api.py
CHANGED
|
@@ -433,7 +433,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
433
433
|
return self.send_json({"error": "unknown route"}, 404)
|
|
434
434
|
|
|
435
435
|
def session_action(self, path):
|
|
436
|
-
"""POST /api/live/<pid>/<interrupt|close|kill|compact|handover>.
|
|
436
|
+
"""POST /api/live/<pid>/<interrupt|close|kill|compact|handover|rename>.
|
|
437
437
|
|
|
438
438
|
Guarded against cross-site requests: a custom header can't be sent by another
|
|
439
439
|
origin without a CORS preflight (which this server never approves), and any
|
|
@@ -451,7 +451,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
451
451
|
agent = (self._payload or {}).get("agent") or "claude"
|
|
452
452
|
if agent != "claude":
|
|
453
453
|
from .procs import act_agent
|
|
454
|
-
if parts[3] in ("handover", "compact"):
|
|
454
|
+
if parts[3] in ("handover", "compact", "rename"):
|
|
455
455
|
return self.send_json({"ok": False,
|
|
456
456
|
"error": f"{parts[3].capitalize()} is only available for Claude Code."})
|
|
457
457
|
return self.send_json(act_agent(int(parts[2]), parts[3], agent))
|
|
@@ -461,6 +461,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
461
461
|
if parts[3] == "compact":
|
|
462
462
|
from .procs import compact
|
|
463
463
|
return self.send_json(compact(int(parts[2]), self._payload))
|
|
464
|
+
if parts[3] == "rename":
|
|
465
|
+
from .procs import rename
|
|
466
|
+
return self.send_json(rename(int(parts[2]), self._payload))
|
|
464
467
|
return self.send_json(act(int(parts[2]), parts[3]))
|
|
465
468
|
|
|
466
469
|
def do_GET(self):
|
|
@@ -590,6 +593,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
590
593
|
if not want or any(x != "claude" for x in want) else []
|
|
591
594
|
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
592
595
|
return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
|
|
596
|
+
if route.startswith("session_prompts/"):
|
|
597
|
+
from .procs import session_prompts
|
|
598
|
+
return self.send_json(session_prompts(_session_transcript(route[len("session_prompts/"):])))
|
|
593
599
|
if route.startswith("guard/session/"):
|
|
594
600
|
return self.send_json(_session_limit_status(a, route[len("guard/session/"):]))
|
|
595
601
|
if route == "subagents":
|
package/finops/procs.py
CHANGED
|
@@ -71,6 +71,12 @@ def _is_claude(p):
|
|
|
71
71
|
return base in ("claude", "claude.exe", "claude.cmd") or cmd.endswith("/claude")
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _same_start(reg, p):
|
|
75
|
+
"""True unless the registry's procStart names a different start time than ps reports.
|
|
76
|
+
Whitespace is normalised: the registry pads single-digit days ("Oct 9"), ps output is re-joined ("Oct 9")."""
|
|
77
|
+
return not reg.get("procStart") or reg["procStart"].split() == p["lstart"].split()
|
|
78
|
+
|
|
79
|
+
|
|
74
80
|
def _ancestors(pid, procs):
|
|
75
81
|
seen = set()
|
|
76
82
|
while pid in procs and pid not in seen and pid > 1:
|
|
@@ -128,8 +134,7 @@ def list_sessions(pricing):
|
|
|
128
134
|
continue
|
|
129
135
|
pid = reg.get("pid")
|
|
130
136
|
p = procs.get(pid)
|
|
131
|
-
alive = bool(p) and _is_claude(p) and (
|
|
132
|
-
not reg.get("procStart") or reg["procStart"] == p["lstart"])
|
|
137
|
+
alive = bool(p) and _is_claude(p) and _same_start(reg, p)
|
|
133
138
|
if not alive:
|
|
134
139
|
continue
|
|
135
140
|
st = _transcript_stats(reg.get("sessionId"), pricing)
|
|
@@ -160,9 +165,14 @@ def act(pid, action):
|
|
|
160
165
|
p = procs.get(pid)
|
|
161
166
|
if not p or not _is_claude(p):
|
|
162
167
|
return {"ok": False, "error": "Process is no longer running."}
|
|
163
|
-
if
|
|
168
|
+
if not _same_start(reg, p):
|
|
164
169
|
return {"ok": False, "error": "PID was reused by another process; refusing."}
|
|
165
170
|
sig, msg = ACTIONS[action]
|
|
171
|
+
if action == "interrupt":
|
|
172
|
+
pane = _herdr_pane(pid)
|
|
173
|
+
if pane and subprocess.run(["herdr", "pane", "send-keys", pane["pane_id"], "esc"],
|
|
174
|
+
capture_output=True, timeout=10).returncode == 0:
|
|
175
|
+
return {"ok": True, "message": "Pressed Esc in that session's herdr pane: the current turn stops."}
|
|
166
176
|
try:
|
|
167
177
|
os.kill(pid, sig)
|
|
168
178
|
except ProcessLookupError:
|
|
@@ -444,11 +454,19 @@ def _render_brief(reg, b, task=None, part=None):
|
|
|
444
454
|
return "\n".join(L)
|
|
445
455
|
|
|
446
456
|
|
|
447
|
-
def _launch_terminal(cwd, prompt_path):
|
|
448
|
-
"""Open a new Terminal window running a fresh `claude` seeded with the brief.
|
|
457
|
+
def _launch_terminal(cwd, prompt_path, herdr_pane=None):
|
|
458
|
+
"""Open a new Terminal window running a fresh `claude` seeded with the brief.
|
|
459
|
+
herdr_pane: the old session's herdr pane; the new session opens as a tab next to it."""
|
|
449
460
|
q = lambda s: "'" + s.replace("'", "'\\''") + "'"
|
|
450
461
|
seed = f"Read the handover brief at {prompt_path} and continue from it."
|
|
451
462
|
cmd = f"cd {q(cwd)} && claude {q(seed)}"
|
|
463
|
+
if herdr_pane:
|
|
464
|
+
tab = _herdr("tab", "create", "--workspace", herdr_pane.get("workspace_id") or "",
|
|
465
|
+
"--cwd", cwd, "--label", "handover", "--focus")
|
|
466
|
+
new = (tab.get("root_pane") or {}).get("pane_id")
|
|
467
|
+
if new and subprocess.run(["herdr", "pane", "run", new, cmd],
|
|
468
|
+
capture_output=True, timeout=10).returncode == 0:
|
|
469
|
+
return True, ""
|
|
452
470
|
if sys.platform == "darwin":
|
|
453
471
|
osa = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(cmd) + "\nend tell"
|
|
454
472
|
r = subprocess.run(["osascript", "-e", osa], capture_output=True, text=True)
|
|
@@ -484,13 +502,14 @@ def handover(pid, opts):
|
|
|
484
502
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
485
503
|
os.makedirs(HANDOFFS, exist_ok=True)
|
|
486
504
|
out, jobs = [], tasks or [None]
|
|
505
|
+
pane = _herdr_pane(pid) if opts.get("launch", True) else None
|
|
487
506
|
for i, t in enumerate(jobs, 1):
|
|
488
507
|
path = os.path.join(HANDOFFS, f"{stamp}-{reg.get('sessionId', 'x')[:8]}-{i}.md")
|
|
489
508
|
with open(path, "w") as fh:
|
|
490
509
|
fh.write(_render_brief(reg, b, t, f"{i} of {len(jobs)}" if len(jobs) > 1 else None))
|
|
491
510
|
item = {"brief": path, "task": t, "launched": False}
|
|
492
511
|
if opts.get("launch", True):
|
|
493
|
-
item["launched"], err = _launch_terminal(reg.get("cwd") or HOME, path)
|
|
512
|
+
item["launched"], err = _launch_terminal(reg.get("cwd") or HOME, path, pane)
|
|
494
513
|
if err:
|
|
495
514
|
item["error"] = err
|
|
496
515
|
out.append(item)
|
|
@@ -507,7 +526,7 @@ def handover(pid, opts):
|
|
|
507
526
|
# /compact is typed *into* the running session — there is no signal for it. Nothing
|
|
508
527
|
# can write to another process's terminal directly (TIOCSTI is off on macOS and on
|
|
509
528
|
# modern Linux), so the keystrokes are handed to whatever owns that terminal:
|
|
510
|
-
# tmux if the session runs inside one, else Terminal.app or iTerm2 via
|
|
529
|
+
# tmux or herdr if the session runs inside one, else Terminal.app or iTerm2 via
|
|
511
530
|
# AppleScript, else Windows Terminal/console via SendKeys. When none of those owns
|
|
512
531
|
# it, the UI falls back to copying the command for you to paste.
|
|
513
532
|
|
|
@@ -553,6 +572,44 @@ def _send_tmux(tty, pid, text):
|
|
|
553
572
|
return False, ""
|
|
554
573
|
|
|
555
574
|
|
|
575
|
+
def _herdr(*args):
|
|
576
|
+
"""Run a herdr CLI call and return its `result` ({} on any failure)."""
|
|
577
|
+
try:
|
|
578
|
+
r = subprocess.run(["herdr", *args], capture_output=True, text=True, timeout=10)
|
|
579
|
+
return (json.loads(r.stdout).get("result") or {}) if r.returncode == 0 else {}
|
|
580
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
581
|
+
return {}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _herdr_pane(pid):
|
|
585
|
+
"""The herdr pane (terminal workspace for AI agents) whose processes include this
|
|
586
|
+
session, or None when herdr isn't installed or the session runs elsewhere."""
|
|
587
|
+
if not shutil.which("herdr"):
|
|
588
|
+
return None
|
|
589
|
+
mine = _ancestors(pid, _ps()) | {pid}
|
|
590
|
+
for pane in _herdr("pane", "list").get("panes") or []:
|
|
591
|
+
if not pane.get("pane_id"):
|
|
592
|
+
continue
|
|
593
|
+
info = _herdr("pane", "process-info", "--pane", pane["pane_id"]).get("process_info") or {}
|
|
594
|
+
pids = {fp.get("pid") for fp in info.get("foreground_processes") or []} | {info.get("shell_pid")}
|
|
595
|
+
if pid in pids or info.get("shell_pid") in mine:
|
|
596
|
+
return pane
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _send_herdr(pid, text):
|
|
601
|
+
"""herdr: type the text and Enter into the session's pane."""
|
|
602
|
+
pane = _herdr_pane(pid)
|
|
603
|
+
if not pane:
|
|
604
|
+
return False, ""
|
|
605
|
+
try:
|
|
606
|
+
r = subprocess.run(["herdr", "pane", "run", pane["pane_id"], text],
|
|
607
|
+
capture_output=True, text=True, timeout=10)
|
|
608
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
609
|
+
return False, ""
|
|
610
|
+
return (True, "herdr") if r.returncode == 0 else (False, (r.stderr or "").strip())
|
|
611
|
+
|
|
612
|
+
|
|
556
613
|
def _send_macos(tty, text):
|
|
557
614
|
"""Terminal.app / iTerm2 both expose each tab's tty, so the right one is addressable."""
|
|
558
615
|
if sys.platform != "darwin" or not tty:
|
|
@@ -642,12 +699,13 @@ def compact(pid, opts=None):
|
|
|
642
699
|
p = _ps().get(pid)
|
|
643
700
|
if not p or not _is_claude(p):
|
|
644
701
|
return {"ok": False, "error": "Process is no longer running.", "copy": text}
|
|
645
|
-
if
|
|
702
|
+
if not _same_start(reg, p):
|
|
646
703
|
return {"ok": False, "error": "PID was reused by another process; refusing.", "copy": text}
|
|
647
704
|
|
|
648
705
|
tty = _tty_of(pid)
|
|
649
706
|
hint = ""
|
|
650
707
|
for send in (lambda: _send_tmux(tty, pid, text),
|
|
708
|
+
lambda: _send_herdr(pid, text),
|
|
651
709
|
lambda: _send_macos(tty, text),
|
|
652
710
|
lambda: _send_windows(pid, text)):
|
|
653
711
|
ok, info = send()
|
|
@@ -661,3 +719,55 @@ def compact(pid, opts=None):
|
|
|
661
719
|
return {"ok": False, "sent": False, "copy": text,
|
|
662
720
|
"error": (hint or "Couldn't reach that session's terminal") +
|
|
663
721
|
f" — `{text}` is on your clipboard; paste it in that window."}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def rename(pid, opts=None):
|
|
725
|
+
"""Type `/rename <name>` into a running Claude Code session's terminal, so Claude Code
|
|
726
|
+
saves the name itself (registry and transcript), exactly as if you typed it there.
|
|
727
|
+
|
|
728
|
+
opts: name – the new name (one line, at most 80 characters)
|
|
729
|
+
"""
|
|
730
|
+
name = " ".join("".join(c if c.isprintable() else " "
|
|
731
|
+
for c in str((opts or {}).get("name") or "")).split())[:80]
|
|
732
|
+
if not name:
|
|
733
|
+
return {"ok": False, "error": "Type a name first."}
|
|
734
|
+
text = f"/rename {name}"
|
|
735
|
+
try:
|
|
736
|
+
with open(os.path.join(REG, f"{pid}.json")) as fh:
|
|
737
|
+
reg = json.load(fh)
|
|
738
|
+
except (OSError, ValueError):
|
|
739
|
+
return {"ok": False, "error": "Not a registered Claude Code session.", "copy": text}
|
|
740
|
+
p = _ps().get(pid)
|
|
741
|
+
if not p or not _is_claude(p):
|
|
742
|
+
return {"ok": False, "error": "Process is no longer running.", "copy": text}
|
|
743
|
+
if not _same_start(reg, p):
|
|
744
|
+
return {"ok": False, "error": "PID was reused by another process; refusing.", "copy": text}
|
|
745
|
+
|
|
746
|
+
tty = _tty_of(pid)
|
|
747
|
+
hint = ""
|
|
748
|
+
for send in (lambda: _send_tmux(tty, pid, text),
|
|
749
|
+
lambda: _send_herdr(pid, text),
|
|
750
|
+
lambda: _send_macos(tty, text),
|
|
751
|
+
lambda: _send_windows(pid, text)):
|
|
752
|
+
ok, info = send()
|
|
753
|
+
if ok:
|
|
754
|
+
pane = _herdr_pane(pid) if info == "herdr" else None
|
|
755
|
+
if pane: # herdr's agents sidebar keeps its own label; name it the same
|
|
756
|
+
_herdr("agent", "rename", pane["pane_id"], name)
|
|
757
|
+
return {"ok": True, "message": f"Sent `{text}` to the session"
|
|
758
|
+
f"{f' via {info}' if info else ''}. "
|
|
759
|
+
"The new name shows here once that session takes it.",
|
|
760
|
+
"sent": True, "copy": text, "name": name}
|
|
761
|
+
if info and not hint:
|
|
762
|
+
hint = info
|
|
763
|
+
return {"ok": False, "sent": False, "copy": text,
|
|
764
|
+
"error": (hint or "Couldn't reach that session's terminal") +
|
|
765
|
+
f" — `{text}` is on your clipboard; paste it in that window."}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def session_prompts(fp, n=5):
|
|
769
|
+
"""The first prompt and the last few, so you can tell which session is which."""
|
|
770
|
+
b = brief_from_transcript(fp)
|
|
771
|
+
recent = b["recent"][1:] if b["turns"] <= 6 else b["recent"]
|
|
772
|
+
return {"first": _clip(b["goal"], 1000), "recent": [_clip(t, 1000) for t in recent[-n:]],
|
|
773
|
+
"turns": b["turns"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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
|
@@ -2107,6 +2107,21 @@ async function drawLimit(box, x, i) {
|
|
|
2107
2107
|
box.querySelector('[data-lim-off]')?.addEventListener('click', () => send({off: true}, 'The guard is off for this conversation.'));
|
|
2108
2108
|
}
|
|
2109
2109
|
|
|
2110
|
+
// The Prompts panel: the session's first prompt and its latest few, so you can tell sessions apart.
|
|
2111
|
+
async function drawPrompts(box, sid) {
|
|
2112
|
+
box.innerHTML = '<div class="note">Reading prompts…</div>';
|
|
2113
|
+
let d;
|
|
2114
|
+
try {
|
|
2115
|
+
const r = await fetch(`/api/session_prompts/${encodeURIComponent(sid)}`);
|
|
2116
|
+
d = await r.json();
|
|
2117
|
+
if (!r.ok) throw new Error(d.error || 'Could not read this session.');
|
|
2118
|
+
} catch (e) { box.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; return; }
|
|
2119
|
+
if (!d.turns) { box.innerHTML = '<div class="note">No prompts yet.</div>'; return; }
|
|
2120
|
+
const one = t => `<div class="dt prompt-txt">${esc(pt(t))}</div>`;
|
|
2121
|
+
box.innerHTML = `<div class="note"><b>First prompt</b> · ${fmtInt(d.turns)} prompt${d.turns === 1 ? '' : 's'} in all</div>${one(d.first)}
|
|
2122
|
+
${d.recent.length ? `<div class="note"><b>Latest prompt${d.recent.length === 1 ? '' : 's'}</b> (newest last)</div>${d.recent.map(one).join('')}` : ''}`;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2110
2125
|
VIEWS.live = async (page) => {
|
|
2111
2126
|
const [res, usage0] = await Promise.all([
|
|
2112
2127
|
fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
|
|
@@ -2129,7 +2144,7 @@ VIEWS.live = async (page) => {
|
|
|
2129
2144
|
</div>
|
|
2130
2145
|
<div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
|
|
2131
2146
|
which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
|
|
2132
|
-
<b>Compact</b> types <code>/compact</code> into that session's terminal (tmux, Terminal.app, iTerm2 or
|
|
2147
|
+
<b>Compact</b> types <code>/compact</code> into that session's terminal (tmux, herdr, Terminal.app, iTerm2 or
|
|
2133
2148
|
Windows console; anywhere else it lands on your clipboard to paste).
|
|
2134
2149
|
<b>Close</b> exits the session cleanly; copy its resume command to bring it back.
|
|
2135
2150
|
<b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
|
|
@@ -2140,7 +2155,7 @@ VIEWS.live = async (page) => {
|
|
|
2140
2155
|
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
2141
2156
|
<div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
2142
2157
|
<span class="pill">${esc(agentName(x.agent))}</span>
|
|
2143
|
-
|
|
2158
|
+
<span class="sname">${esc((!MASKED && x.name) || shortId(x.session_id))}</span> <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
2144
2159
|
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
2145
2160
|
${x.agent === 'claude' ? limitBadge(x.session_id) : ''}
|
|
2146
2161
|
<span class="spacer"></span>
|
|
@@ -2159,6 +2174,8 @@ VIEWS.live = async (page) => {
|
|
|
2159
2174
|
${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
|
|
2160
2175
|
${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
|
|
2161
2176
|
${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
|
|
2177
|
+
${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-pr="1">' + I('message') + ' Prompts</button>' : ''}
|
|
2178
|
+
${x.agent === 'claude' && x.signalable ? '<button class="act ghost" data-rn="1">' + I('tag') + ' Rename</button>' : ''}
|
|
2162
2179
|
<span class="live-msg"></span>
|
|
2163
2180
|
</div>
|
|
2164
2181
|
${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
|
|
@@ -2174,6 +2191,15 @@ VIEWS.live = async (page) => {
|
|
|
2174
2191
|
<div class="ho-out note"></div>
|
|
2175
2192
|
</div>`}
|
|
2176
2193
|
${x.agent === 'claude' && x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
|
|
2194
|
+
${x.agent === 'claude' && x.session_id ? `<div class="handover promptbox" hidden></div>` : ''}
|
|
2195
|
+
${x.agent === 'claude' && x.signalable ? `<div class="handover renamebox" hidden>
|
|
2196
|
+
<div class="dt">Types <code>/rename <name></code> into that session's terminal, so Claude Code saves the
|
|
2197
|
+
name itself (in herdr, the agents sidebar gets the same name). If the terminal can't be reached, the command lands on your clipboard to paste.</div>
|
|
2198
|
+
<div class="live-actions">
|
|
2199
|
+
<input type="text" class="rn-in" maxlength="80" placeholder="New name for this session" value="${esc(MASKED ? '' : (x.name || ''))}">
|
|
2200
|
+
<button class="act rn-go">Rename</button>
|
|
2201
|
+
<span class="live-msg rn-msg" role="status"></span>
|
|
2202
|
+
</div></div>` : ''}
|
|
2177
2203
|
</div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
|
|
2178
2204
|
$('#live-refresh', page).onclick = () => render();
|
|
2179
2205
|
const ub = $('#usage-refresh', page);
|
|
@@ -2207,13 +2233,37 @@ VIEWS.live = async (page) => {
|
|
|
2207
2233
|
ho.querySelector('.ho-go').onclick = e => runHandover(true, e.currentTarget);
|
|
2208
2234
|
ho.querySelector('.ho-brief').onclick = e => runHandover(false, e.currentTarget);
|
|
2209
2235
|
}
|
|
2210
|
-
row.
|
|
2236
|
+
const rn = row.querySelector('.renamebox');
|
|
2237
|
+
if (rn) {
|
|
2238
|
+
const inp = rn.querySelector('.rn-in'), go = rn.querySelector('.rn-go'), rmsg = rn.querySelector('.rn-msg');
|
|
2239
|
+
const doRename = async () => {
|
|
2240
|
+
if (!inp.value.trim()) { rmsg.className = 'live-msg rn-msg err'; rmsg.textContent = 'Type a name first.'; return; }
|
|
2241
|
+
go.disabled = true; go.textContent = 'Working…';
|
|
2242
|
+
try {
|
|
2243
|
+
const r = await sessionAction(pid, 'rename', {name: inp.value});
|
|
2244
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
2245
|
+
rmsg.className = 'live-msg rn-msg ' + (r.ok ? 'ok' : 'err');
|
|
2246
|
+
rmsg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
2247
|
+
if (r.ok && !MASKED) row.querySelector('.sname').textContent = r.name;
|
|
2248
|
+
} catch (e) { rmsg.className = 'live-msg rn-msg err'; rmsg.textContent = 'Failed: ' + e.message; }
|
|
2249
|
+
go.disabled = false; go.textContent = 'Rename';
|
|
2250
|
+
};
|
|
2251
|
+
go.onclick = doRename;
|
|
2252
|
+
inp.onkeydown = e => { if (e.key === 'Enter') doRename(); };
|
|
2253
|
+
}
|
|
2254
|
+
row.querySelectorAll('button.act:not(.ho-go):not(.ho-brief):not(.rn-go)').forEach(b => {
|
|
2211
2255
|
if (b.dataset.ho) { b.onclick = () => { ho.hidden = !ho.hidden; if (!ho.hidden) ta.focus(); }; return; }
|
|
2212
2256
|
if (b.dataset.lim) {
|
|
2213
2257
|
const box = row.querySelector('.limitbox');
|
|
2214
2258
|
b.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) drawLimit(box, x, +row.dataset.i); };
|
|
2215
2259
|
return;
|
|
2216
2260
|
}
|
|
2261
|
+
if (b.dataset.pr) {
|
|
2262
|
+
const box = row.querySelector('.promptbox');
|
|
2263
|
+
b.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) drawPrompts(box, x.session_id); };
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (b.dataset.rn) { b.onclick = () => { rn.hidden = !rn.hidden; if (!rn.hidden) rn.querySelector('.rn-in').focus(); }; return; }
|
|
2217
2267
|
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; }
|
|
2218
2268
|
const label = b.textContent;
|
|
2219
2269
|
b.onclick = async () => {
|
|
@@ -4315,7 +4365,7 @@ const TOURS = {
|
|
|
4315
4365
|
live: [
|
|
4316
4366
|
{el: 'kpis', t: 'Running now', see: 'Sessions running right now, which are working (spending) at this moment, the largest live context and what live sessions have spent.', get: 'What is costing you money this second.', act: 'Only <b>working</b> sessions consume tokens; idle ones cost again on your next message.'},
|
|
4317
4367
|
{el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
|
|
4318
|
-
{el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command,
|
|
4368
|
+
{el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, <b>Hand over</b> a Claude session to a fresh one, see its <b>Prompts</b> or <b>Rename</b> it.'}],
|
|
4319
4369
|
scorecard: [
|
|
4320
4370
|
{el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
|
|
4321
4371
|
{el: 'card:What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
|
package/web/styles.css
CHANGED
|
@@ -497,6 +497,8 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
497
497
|
border: 1px solid var(--border); background: transparent; color: inherit; resize: vertical; }
|
|
498
498
|
.handover .ho-out { display: grid; gap: 4px; }
|
|
499
499
|
.handover .ho-out code { word-break: break-all; }
|
|
500
|
+
.handover .prompt-txt { white-space: pre-wrap; word-break: break-word; max-height: 160px; overflow: auto; padding: 6px 8px; border-radius: 6px; background: var(--surface-2); }
|
|
501
|
+
.handover .rn-in { flex: 1; min-width: 180px; font: inherit; font-size: 12.5px; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: inherit; }
|
|
500
502
|
|
|
501
503
|
/* ---------- walkthrough ---------- */
|
|
502
504
|
#tour { position: fixed; inset: 0; z-index: 1000; pointer-events: none; }
|