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 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, one per sub-task if
445
- you list them; it can close the old session (resumable).
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 reg.get("procStart") and reg["procStart"] != p["lstart"]:
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 reg.get("procStart") and reg["procStart"] != p["lstart"]:
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.15.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
- ${esc((!MASKED && x.name) || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
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 &lt;name&gt;</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.querySelectorAll('button.act:not(.ho-go):not(.ho-brief)').forEach(b => {
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, or <b>Hand over</b> a Claude session to a fresh one.'}],
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; }