claude-finops 0.9.0 → 0.10.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
@@ -63,6 +63,13 @@ npm install -g claude-finops # then `claude-finops` from anywhere
63
63
 
64
64
  ## What you actually get
65
65
 
66
+ **It acts, not just reports.** The overview opens with **⚡ Act now**: one-click
67
+ actions that apply to you right now. Compact a running session that is carrying too
68
+ much context (it types `/compact` into that session's terminal), hand it over to a
69
+ fresh session, turn a shell command you keep re-running into a skill, or move an
70
+ instruction you keep re-typing into `CLAUDE.md`. Every session row has **Resume**
71
+ (copies `claude --resume <id>`), and running ones have **Compact**.
72
+
66
73
  **Where the money goes, per project.** Every project ranked by cost, drilling down
67
74
  Project → Session → Prompt.
68
75
 
@@ -140,17 +147,21 @@ labelled as your own configured figures.
140
147
 
141
148
  ## What is in it
142
149
 
143
- **Command center** — Executive overview (spend, tokens, usage %, remaining, forecast),
144
- an AI FinOps Advisor that answers "what should I do today?" from live data, and a
150
+ **Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
151
+ today vs yesterday) opening with the ⚡ Act now strip, running sessions you can
152
+ interrupt, compact, hand over or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
145
153
  0–100 FinOps scorecard with per-dimension reasoning.
146
154
 
147
155
  **Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
148
- drill-down; burn rate and limits with a gauge, days-until-limit and projected overage;
156
+ drill-down, plus a weekday × hour peak-hours heatmap in your local time; burn rate and
157
+ limits with a gauge, days-until-limit and projected overage, and your 5-hour and weekly
158
+ plan-limit history from the Claude desktop app;
149
159
  per-model FinOps table with superlatives (most expensive, most used, most
150
160
  token-efficient, best cost-per-output); context-size distribution and cache
151
161
  with-vs-without analysis.
152
162
 
153
- **Drill-down** — Project → Session → Prompt, everywhere. A prompt explorer over every
163
+ **Drill-down** — Project → Session → Prompt, everywhere, with Resume (and Compact for
164
+ running sessions) on every session row. A prompt explorer over every
154
165
  prompt with full text, category, tokens, cache split, tool calls, files touched,
155
166
  latency and efficiency; five leaderboards; prompt intelligence (spend by activity);
156
167
  and a Claude Code view (tools, files, branches, cost per repository).
@@ -212,12 +223,14 @@ depends entirely on how agentic your workload is.
212
223
 
213
224
  ```
214
225
  ~/.claude/projects/**/*.jsonl
226
+ <Claude desktop app>/local-agent-mode-sessions/**/.claude/projects/**/*.jsonl (Cowork)
215
227
  │
216
228
  ▼ finops/etl.py stream-parse, normalize, roll up
217
229
  ~/.claude-finops/data/finops.db SQLite warehouse
218
230
  │
219
231
  ▼ finops/analytics.py KPIs · burn · forecast · waste · anomalies · scorecard
220
232
  finops/api.py stdlib HTTP: JSON API + static files (127.0.0.1 only)
233
+ (+ <Claude desktop app>/plan-usage-history.json, read on request)
221
234
  │
222
235
  ▼ web/ vanilla JS, inline-SVG charts, no build step
223
236
  ```
@@ -262,6 +275,8 @@ claude-finops --stop stop it
262
275
  claude-finops --where where your data, settings and keys live
263
276
  claude-finops --set-key store a provider API key (hidden prompt, 0600)
264
277
  claude-finops --keys which provider keys are configured
278
+ claude-finops --install-statusline model, context % and 5-hour limit under every prompt
279
+ claude-finops --uninstall-statusline remove it again
265
280
  claude-finops --help everything
266
281
  ```
267
282
 
@@ -293,6 +308,12 @@ place for you to delete once you are happy.
293
308
 
294
309
  ## Privacy
295
310
 
311
+ **What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
312
+ if the Claude desktop app is installed, its Cowork transcripts and
313
+ `plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
314
+ `%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux), plus the other coding
315
+ agents listed under Multi-agent.
316
+
296
317
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
297
318
  server binds to `127.0.0.1` only, but treat the database and any export you
298
319
  generate as sensitive.
@@ -310,7 +331,8 @@ configure a key with `--set-key`.
310
331
 
311
332
  The app has nothing tied to one person. Whoever runs it sees **their own** Claude usage:
312
333
 
313
- - It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`).
334
+ - It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`),
335
+ plus the Claude desktop app's Cowork sessions when the default location is used.
314
336
  - The account label comes from that machine's `~/.claude.json`.
315
337
  - Budgets and limits you set in the UI are saved to `~/.claude-finops/settings.local.json`. The committed `config/settings.json` holds only shared defaults.
316
338
 
@@ -344,6 +366,19 @@ files on Windows.
344
366
  `config/free_models.json`.
345
367
  - **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
346
368
  "Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
369
+ The strongest skill suggestions also appear in **⚡ Act now** on the overview.
370
+ - **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
371
+ terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
372
+ command for you to paste. It asks you to click twice.
373
+ - **Hand over / split** (Running sessions) writes a brief of the session to
374
+ `~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
375
+ you list them; it can close the old session (resumable).
376
+ - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
377
+ Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
378
+ interrupt one console process without hitting the others in its window.
379
+ - **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
380
+ `~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
381
+ already have.
347
382
 
348
383
  ---
349
384
 
@@ -0,0 +1,92 @@
1
+ """What to do right now: the few one-click actions that apply to this machine today.
2
+
3
+ Everything here already exists deeper in the dashboard (Running sessions, Skills &
4
+ MCP, Why so many tokens?). This picks the high-signal items only, so the overview
5
+ can act instead of just reporting:
6
+
7
+ session a running session carrying heavy context -> Compact / Hand over
8
+ skill a shell command you keep re-running -> Create skill
9
+ memory an instruction you keep re-typing -> Add to CLAUDE.md
10
+ statusline no statusline configured -> Install
11
+
12
+ Prompt-based skill suggestions and one-off memory lines are left to their own pages:
13
+ they are noisy enough that a one-click card would mostly be wrong.
14
+ """
15
+
16
+ MAX_SESSIONS = 3
17
+ MAX_SKILLS = 2
18
+ MAX_MEMORY = 2
19
+
20
+
21
+ def _k(n):
22
+ return f"{n / 1000:.0f}K" if n < 1_000_000 else f"{n / 1_000_000:.1f}M"
23
+
24
+
25
+ def build(live, skills, memory, statusline):
26
+ """live: procs.list_sessions rows; skills: actions.suggestions()["skills"];
27
+ memory: Diagnoser.memory_suggestions(); statusline: integrate.statusline_state()."""
28
+ items = []
29
+ heavy = sorted((x for x in live if x.get("severity") in ("medium", "high") and x.get("context")),
30
+ key=lambda x: -x["context"])[:MAX_SESSIONS]
31
+ for x in heavy:
32
+ items.append({
33
+ "kind": "session", "severity": x["severity"], "pid": x.get("pid"),
34
+ "session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
35
+ "title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
36
+ "detail": f"{x.get('project') or ''} · every message re-reads all of it. "
37
+ + ("Hand it over to a fresh session." if x["severity"] == "high"
38
+ else "Compact it at the next break."),
39
+ })
40
+ cmds = sorted((s for s in skills if s.get("kind") == "command" and not s.get("installed")),
41
+ key=lambda s: -s.get("runs", 0))[:MAX_SKILLS]
42
+ for s in cmds:
43
+ items.append({
44
+ "kind": "skill", "skill": s,
45
+ "title": f"You ran `{s['trigger']}` {s['runs']} times in {s['sessions']} sessions",
46
+ "detail": "Make it a skill so Claude runs it the way this project expects, "
47
+ "without re-explaining each time.",
48
+ })
49
+ themes = sorted((m for m in memory if m.get("kind") == "theme" and not m.get("already_saved")),
50
+ key=lambda m: -m.get("sessions", 0))[:MAX_MEMORY]
51
+ for m in themes:
52
+ items.append({
53
+ "kind": "memory",
54
+ "title": f"You keep re-typing instructions about: {m['text']}",
55
+ "detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
56
+ })
57
+ if statusline is None:
58
+ items.append({
59
+ "kind": "statusline",
60
+ "title": "See model, context % and 5-hour limit under every Claude Code prompt",
61
+ "detail": "Adds a statusline to ~/.claude/settings.json (a backup is kept). "
62
+ "Undo any time with claude-finops --uninstall-statusline.",
63
+ })
64
+ return {"items": items}
65
+
66
+
67
+ def act_now(a):
68
+ """Gather the live inputs and build the strip. Each source fails soft."""
69
+ from .procs import list_sessions
70
+ from .actions import suggestions
71
+ from .diagnose import Diagnoser
72
+ from .integrate import statusline_state
73
+
74
+ def safe(fn, default):
75
+ try:
76
+ return fn()
77
+ except Exception:
78
+ return default
79
+
80
+ live = safe(lambda: list_sessions(a.pricing), [])
81
+ # the process registry names sessions after their folder; the transcript title reads better
82
+ ids = [x["session_id"] for x in live if x.get("session_id")]
83
+ titles = safe(lambda: {r["id"]: r["title"] for r in a.q(
84
+ "SELECT id, title FROM sessions WHERE title IS NOT NULL AND id IN (%s)" % ",".join("?" * len(ids)),
85
+ ids)} if ids else {}, {})
86
+ for x in live:
87
+ if titles.get(x.get("session_id")):
88
+ x["name"] = titles[x["session_id"]]
89
+ return build(live,
90
+ safe(lambda: suggestions(a)["skills"], []),
91
+ safe(lambda: Diagnoser(a).memory_suggestions({}), []),
92
+ safe(statusline_state, "other"))
@@ -168,6 +168,16 @@ def _cumsum(values):
168
168
  yield total
169
169
 
170
170
 
171
+ def resume_command(sid, agent, source_file):
172
+ """`claude --resume <id>` for a Claude Code session; None where it would not work.
173
+
174
+ Cowork transcripts live under the desktop app's own config dir, where
175
+ `claude --resume` would not find them.
176
+ """
177
+ cowork = "local-agent-mode-sessions" in (source_file or "")
178
+ return f"claude --resume {sid}" if (agent or "claude") == "claude" and not cowork else None
179
+
180
+
171
181
  class Analytics:
172
182
  def __init__(self, db_path=DB_PATH):
173
183
  # One connection per thread. The HTTP server is threaded, and a single sqlite
@@ -561,6 +571,7 @@ class Analytics:
561
571
  rows = self.q(f"""
562
572
  SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
563
573
  s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
574
+ s.agent, s.source_file,
564
575
  COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
565
576
  SUM(r.tool_call_count) tool_calls,
566
577
  SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
@@ -579,6 +590,7 @@ class Analytics:
579
590
  r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
580
591
  cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
581
592
  r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
593
+ r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
582
594
  return rows
583
595
 
584
596
  def sessions_total(self, f=None):
@@ -699,11 +711,7 @@ class Analytics:
699
711
  s["files"] = self.q(
700
712
  "SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
701
713
  " WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
702
- # Cowork transcripts live under the desktop app's own config dir, where
703
- # `claude --resume` would not find them.
704
- cowork = "local-agent-mode-sessions" in (s.get("source_file") or "")
705
- s["resume"] = (f"claude --resume {sid}"
706
- if (s.get("agent") or "claude") == "claude" and not cowork else None)
714
+ s["resume"] = resume_command(sid, s.get("agent"), s.get("source_file"))
707
715
  return s
708
716
 
709
717
  # ---------------- categories ----------------
@@ -725,7 +733,7 @@ class Analytics:
725
733
 
726
734
  # ---------------- leaderboards ----------------
727
735
  def leaderboards(self, f=None, n=20):
728
- return {
736
+ out = {
729
737
  "most_expensive": self.prompts(f, limit=n, order="cost"),
730
738
  "most_token_heavy": self.prompts(f, limit=n, order="tokens"),
731
739
  "cheapest": self.prompts(f, limit=n, order="cheapest"),
@@ -733,6 +741,17 @@ class Analytics:
733
741
  "longest_sessions": self.sessions(f, limit=n, order="duration"),
734
742
  "basis": "estimated",
735
743
  }
744
+ prompt_rows = [r for k in ("most_expensive", "most_token_heavy", "cheapest", "most_efficient")
745
+ for r in out[k]]
746
+ sids = sorted({r["session_id"] for r in prompt_rows if r.get("session_id")})
747
+ src = {r["id"]: r for r in self.q(
748
+ "SELECT id, agent, source_file FROM sessions WHERE id IN (%s)" % ",".join("?" * len(sids)),
749
+ sids)} if sids else {}
750
+ for r in prompt_rows:
751
+ s = src.get(r.get("session_id")) or {}
752
+ r["resume"] = (resume_command(r["session_id"], s.get("agent"), s.get("source_file"))
753
+ if s else None)
754
+ return out
736
755
 
737
756
  # ---------------- efficiency ----------------
738
757
  def hygiene(self, f=None, top=12, trajectory_points=80):
package/finops/api.py CHANGED
@@ -270,6 +270,9 @@ class Handler(BaseHTTPRequestHandler):
270
270
  return self.send_json({"job": j["id"]})
271
271
  if parts == ["skill"]:
272
272
  return self.send_json(X.create_skill(payload))
273
+ if parts == ["statusline"]:
274
+ from .integrate import install_statusline
275
+ return self.send_json(install_statusline())
273
276
  if len(parts) == 2 and parts[0] == "mcp":
274
277
  return self.send_json(X.add_mcp(parts[1], payload))
275
278
  return self.send_json({"error": "unknown action"}, 404)
@@ -390,6 +393,9 @@ class Handler(BaseHTTPRequestHandler):
390
393
  return self.send_json(a.timeline(f, g("grain", "day")))
391
394
  if route == "heatmap":
392
395
  return self.send_json(a.heatmap(f))
396
+ if route == "act_now":
397
+ from .act_now import act_now
398
+ return self.send_json(act_now(a))
393
399
  if route == "models":
394
400
  return self.send_json(a.models(f))
395
401
  if route == "projects":
@@ -12,6 +12,7 @@ fail silent, never block, never take long.
12
12
  """
13
13
  import json
14
14
  import os
15
+ import shlex
15
16
  import shutil
16
17
  import sys
17
18
 
@@ -59,14 +60,17 @@ def hook():
59
60
  # -------------------------------------------------------------- statusline ----
60
61
 
61
62
  def statusline():
62
- """One line, refreshed constantly: model and context usage."""
63
+ """One line, refreshed constantly: model, context usage and the 5-hour limit."""
63
64
  try:
64
65
  d = _stdin_json()
65
- pct = _dig(d, "context.percentUsed", "context.percent_used")
66
+ pct = _dig(d, "context_window.used_percentage", "context.percentUsed", "context.percent_used")
67
+ five = _dig(d, "rate_limits.five_hour.used_percentage")
66
68
  name = _dig(d, "model.display_name", "session.model", "model") or "claude"
67
69
  bits = [str(name)]
68
70
  if isinstance(pct, (int, float)):
69
71
  bits.append(f"{pct:.0f}% ctx")
72
+ if isinstance(five, (int, float)):
73
+ bits.append(f"{five:.0f}% 5h")
70
74
  print(" · ".join(bits))
71
75
  except Exception:
72
76
  print("") # an empty statusline beats a stack trace under the prompt
@@ -81,7 +85,8 @@ def _command():
81
85
  if exe:
82
86
  return exe
83
87
  root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
84
- return f"{sys.executable} {os.path.join(root, 'run.py')}"
88
+ # settings run this through a shell: an install path with a space would split
89
+ return f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
85
90
 
86
91
 
87
92
  def _load_settings():
@@ -113,21 +118,31 @@ def install_hook(remove=False):
113
118
  print("The statusline (claude-finops --install-statusline) still shows model and context %.")
114
119
 
115
120
 
121
+ def statusline_state():
122
+ """"ours" when our statusline is installed, "other" for someone else's, else None."""
123
+ cur = _load_settings().get("statusLine")
124
+ if not cur:
125
+ return None
126
+ return "ours" if "finops" in json.dumps(cur) else "other"
127
+
128
+
116
129
  def install_statusline(remove=False):
117
- cmd = f"{_command()} --statusline"
130
+ """Wire the statusline into ~/.claude/settings.json. Returns {"ok", "message"}.
131
+
132
+ Claude Code wants {"type": "command", "command": ...}; an existing statusline that
133
+ isn't ours is left alone.
134
+ """
118
135
  s = _load_settings()
119
136
  if remove:
120
- if "finops" in str(s.get("statusLine", "")):
137
+ if statusline_state() == "ours":
121
138
  s.pop("statusLine", None)
122
- else:
123
- prev = s.get("statusLine")
124
- if prev and "finops" not in str(prev):
125
- print(f"You already have a statusLine configured:\n {prev}")
126
- print("Leaving it alone. Remove it first if you want ours.")
127
- return
128
- s["statusLine"] = cmd
139
+ _save_settings(s)
140
+ return {"ok": True, "message": f"Removed the statusline from {SETTINGS}"}
141
+ if statusline_state() == "other":
142
+ return {"ok": False, "message": "You already have a statusline configured "
143
+ f"({json.dumps(s['statusLine'])}). Left it alone: remove it first if you want ours."}
144
+ s["statusLine"] = {"type": "command", "command": f"{_command()} --statusline"}
129
145
  _save_settings(s)
130
- print(("Removed" if remove else "Installed") + f" the statusline in {SETTINGS}")
131
- if not remove:
132
- print(" Shows the model and context usage percentage.")
133
- print(" Undo: claude-finops --uninstall-statusline")
146
+ return {"ok": True, "message": f"Installed the statusline in {SETTINGS}. It shows the model, "
147
+ "context % and 5-hour limit % under every prompt. "
148
+ "Undo: claude-finops --uninstall-statusline"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.9.0",
3
+ "version": "0.10.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
@@ -153,7 +153,7 @@ HELP = """Claude FinOps Command Center
153
153
  claude-finops --version print the installed version, and whether a newer one is out
154
154
  claude-finops --no-update-check skip the once-a-day npm version check
155
155
  claude-finops --install-hook retired no-op (prints a message, changes nothing)
156
- claude-finops --install-statusline show model and context % in your statusline
156
+ claude-finops --install-statusline show model, context % and 5-hour limit in your statusline
157
157
  claude-finops --help this message
158
158
 
159
159
  Environment:
@@ -296,7 +296,9 @@ def main():
296
296
  return install_hook(remove="--uninstall-hook" in args)
297
297
  if "--install-statusline" in args or "--uninstall-statusline" in args:
298
298
  from finops.integrate import install_statusline
299
- return install_statusline(remove="--uninstall-statusline" in args)
299
+ r = install_statusline(remove="--uninstall-statusline" in args)
300
+ print(r["message"])
301
+ return sys.exit(0 if r["ok"] else 1)
300
302
  if "--where" in args:
301
303
  return where()
302
304
  if "--keys" in args:
package/web/app.js CHANGED
@@ -685,10 +685,110 @@ VIEWS.overview = async (page) => {
685
685
  api('diagnose').then(d => {
686
686
  if (!page.isConnected) return;
687
687
  const el = h(focusStrip(d) || '<div></div>');
688
- page.insertBefore(el, page.firstChild);
688
+ page.insertBefore(el, page.querySelector('.actnow')?.nextSibling || page.firstChild);
689
+ page._focusData = d;
689
690
  wireFocus(page);
690
691
  }).catch(() => {});
692
+ if (hasClaude()) fetch('/api/act_now').then(r => r.json()).then(d => {
693
+ if (!page.isConnected || !(d.items || []).length) return;
694
+ const el = h(actNowStrip(d.items));
695
+ page.insertBefore(el, page.firstChild);
696
+ wireActNow(el, d.items);
697
+ }).catch(() => {});
698
+ };
699
+
700
+ /* ---------- act now: one-click actions, on the page everyone opens first ---------- */
701
+ const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
702
+ const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
703
+ function actNowStrip(items) {
704
+ const btn = it => it.kind === 'session'
705
+ ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>'
706
+ + '<button class="act ghost" data-do="handover">⇢ Hand over</button>'
707
+ : it.kind === 'skill' ? '<button class="act" data-do="skill">+ Create skill</button>'
708
+ : it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
709
+ : '<button class="act" data-do="statusline">Install statusline</button>';
710
+ return `<section class="actnow"><div class="focus-hd">⚡ Act now
711
+ <span class="note">one click each · nothing changes until you click</span></div>
712
+ ${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
713
+ <span class="ai">${ACT_ICON[it.kind] || '•'}</span>
714
+ <div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
715
+ <div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
716
+ }
717
+ function wireActNow(root, items) {
718
+ root.querySelectorAll('.actnow-it').forEach(row => {
719
+ const it = items[+row.dataset.i], msg = row.querySelector('.live-msg');
720
+ const say = (t, ok) => { msg.textContent = t; msg.className = 'live-msg ' + (ok ? 'ok' : 'err'); };
721
+ row.querySelectorAll('[data-do]').forEach(b => b.onclick = async () => {
722
+ const what = b.dataset.do;
723
+ if (what === 'handover') return go('live');
724
+ if (what === 'memory') return jumpTo('diagnose', 'dx-mem');
725
+ if (what === 'compact' && !armed(b)) return;
726
+ b.disabled = true;
727
+ try {
728
+ if (what === 'compact') {
729
+ const r = await sessionAction(it.pid, 'compact', {agent: 'claude'});
730
+ if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
731
+ say(r.ok ? r.message : `Failed: ${r.error}`, r.ok);
732
+ b.disabled = false; return;
733
+ }
734
+ const r = what === 'skill' ? await doAction('skill', it.skill) : await doAction('statusline');
735
+ say(what === 'skill' ? `Created ${r.path}. Use it with ${r.use}.` : r.message, r.ok !== false);
736
+ if (r.ok !== false) b.remove(); else b.disabled = false;
737
+ } catch (e) { say('Failed: ' + e.message, false); b.disabled = false; }
738
+ });
739
+ });
740
+ }
741
+ // Two-click confirm, inline (no browser dialogs): the first click arms, the second runs.
742
+ function armed(b) {
743
+ if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; return true; }
744
+ b.dataset.label = b.textContent; b.classList.add('armed'); b.textContent = 'Click again to confirm';
745
+ setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; } }, 4000);
746
+ return false;
747
+ }
748
+ // Open a view and scroll to an element that appears once the (possibly slow) view renders.
749
+ async function jumpTo(view, anchor) {
750
+ if (S.view !== view) go(view);
751
+ for (let i = 0; i < 60; i++) {
752
+ const el = document.getElementById(anchor);
753
+ if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
754
+ setTimeout(() => el.classList.remove('flash'), 1600); return; }
755
+ await new Promise(r => setTimeout(r, 250));
756
+ }
757
+ }
758
+
759
+ /* ---------- row actions: Resume (copy) and, for a running session, Compact ---------- */
760
+ async function liveSessions() {
761
+ try {
762
+ const d = await fetch('/api/live?agents=claude').then(r => r.json());
763
+ return new Map((d.sessions || []).filter(x => x.signalable && x.pid).map(x => [x.session_id, x.pid]));
764
+ } catch { return new Map(); }
765
+ }
766
+ const rowActs = (r, live) => {
767
+ const pid = live && live.get(r.session_id);
768
+ return `<span class="row-acts">${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}"
769
+ title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
770
+ title="Running now: types /compact into its terminal">🗜 Compact</button>` : ''}</span>`;
691
771
  };
772
+ function wireRowActs(host) {
773
+ if (!host) return;
774
+ host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
775
+ e.stopPropagation();
776
+ try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
777
+ catch { b.textContent = b.dataset.resume; }
778
+ setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
779
+ });
780
+ host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
781
+ e.stopPropagation();
782
+ if (!armed(b)) return;
783
+ b.disabled = true;
784
+ try {
785
+ const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
786
+ if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
787
+ b.textContent = r.ok ? '✓ Sent' : 'Failed'; b.title = r.ok ? r.message : r.error;
788
+ } catch (err) { b.textContent = 'Failed'; b.title = err.message; }
789
+ setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
790
+ });
791
+ }
692
792
 
693
793
  function renderAdvisorHero(page, advisor) {
694
794
  if (!page || !advisor || !Array.isArray(advisor.actions)) return;
@@ -1076,7 +1176,7 @@ VIEWS.sessions = async (page) => {
1076
1176
  const sp = S.sessPage;
1077
1177
  const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
1078
1178
  const rows = res.rows, total = res.total;
1079
- const eff = await api('efficiency');
1179
+ const [eff, live] = await Promise.all([api('efficiency'), liveSessions()]);
1080
1180
  const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
1081
1181
  page.innerHTML = `
1082
1182
  <div class="grid g4">
@@ -1121,6 +1221,7 @@ VIEWS.sessions = async (page) => {
1121
1221
  {h: 'Session', trunc: 1, title: r => r.session_id,
1122
1222
  f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
1123
1223
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1224
+ {h: '', f: r => rowActs(r, live)},
1124
1225
  {h: 'Project', f: r => esc(r.project)},
1125
1226
  {h: 'Branch', f: r => r.git_branch ? `<span class="mono">${esc(r.git_branch)}</span>` : '—'},
1126
1227
  {h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
@@ -1141,6 +1242,7 @@ VIEWS.sessions = async (page) => {
1141
1242
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
1142
1243
  ], rows, {onRow: 1});
1143
1244
  wireTable($('#st', page), rows, r => openSession(r.session_id));
1245
+ wireRowActs($('#st', page));
1144
1246
  const effCards = [...page.querySelectorAll('.card')].filter(c =>
1145
1247
  /output yield/.test(c.querySelector('h3')?.textContent || ''));
1146
1248
  wireTable(effCards[0], eff.low_efficiency_sessions, r => openSession(r.session_id));
@@ -1219,7 +1321,7 @@ const RANK_TABS = [
1219
1321
  ['cheapest', 'Top 20 cheapest'], ['most_efficient', 'Top 20 most efficient'],
1220
1322
  ['longest_sessions', 'Top 20 longest sessions']];
1221
1323
  VIEWS.rankings = async (page) => {
1222
- const lb = await api('leaderboards', '&n=20');
1324
+ const [lb, live] = await Promise.all([api('leaderboards', '&n=20'), liveSessions()]);
1223
1325
  const tab = S.rankTab || 'most_expensive';
1224
1326
  const isSess = tab === 'longest_sessions';
1225
1327
  const rows = lb[tab];
@@ -1233,6 +1335,7 @@ VIEWS.rankings = async (page) => {
1233
1335
  const cols = isSess ? [
1234
1336
  {h: '#', num: 1, f: r => rows.indexOf(r) + 1},
1235
1337
  {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1338
+ {h: '', f: r => rowActs(r, live)},
1236
1339
  {h: 'Project', f: r => esc(r.project)},
1237
1340
  {h: 'Duration', num: 1, f: r => dur(r.duration_s)},
1238
1341
  {h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
@@ -1251,10 +1354,12 @@ VIEWS.rankings = async (page) => {
1251
1354
  {h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
1252
1355
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
1253
1356
  {h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
1357
+ {h: '', f: r => rowActs(r, live)},
1254
1358
  {h: 'Date', f: r => esc(r.day)},
1255
1359
  ];
1256
1360
  $('#rt', page).innerHTML = table(cols, rows, {onRow: 1});
1257
1361
  wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
1362
+ wireRowActs($('#rt', page));
1258
1363
  addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
1259
1364
  rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
1260
1365
  sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
@@ -2297,45 +2402,81 @@ VIEWS.exports = async (page) => {
2297
2402
  /* ============================ focus cues ============================ */
2298
2403
  // "What needs me first": one ranked list built from the diagnosis, used for the nav
2299
2404
  // badges and the Focus strip at the top of the overview and diagnosis pages.
2405
+ // "What to change": things to fix once and habits to change, each with its reason.
2406
+ // Running sessions are left to ⚡ Act now, which can act on them. Items are keyed so a
2407
+ // dismissed one comes back only if its evidence changes (e.g. more sessions affected).
2408
+ const firstSentence = t => { const m = String(t || '').match(/^.*?[.!?](\s|$)/); return (m ? m[0] : String(t || '')).trim(); };
2409
+ // "29.7% of X." + "35.6% of X." -> "29.7–35.6% of X." plus the shared advice; null when they differ.
2410
+ function sameFix(fixes) {
2411
+ const pct = /\d+(?:\.\d+)?%/, heads = fixes.map(firstSentence);
2412
+ const shape = heads.map(t => t.replace(pct, '#'));
2413
+ if (!shape.every(t => t === shape[0]) || !pct.test(heads[0])) return null;
2414
+ const nums = heads.map(t => parseFloat(t.match(pct)[0]));
2415
+ const range = `${Math.min(...nums)}–${Math.max(...nums)}%`;
2416
+ const rest = fixes[0].slice(heads[0].length).trim().replace(/\bthis repo\b/g, 'each repo');
2417
+ return `${shape[0].replace('#', range).replace(' here ', ' in these repos ')} ${rest}`.trim();
2418
+ }
2300
2419
  function focusItems(d) {
2301
2420
  const out = [];
2302
- for (const x of d.live_sessions || []) if (x.severity !== 'ok')
2303
- out.push({lvl: x.severity === 'high' ? 1 : 2, text: `Running session at ${fmtNum(x.context)} context: ${x.title || shortId(x.session_id)}`, view: 'diagnose', anchor: 'dx-live'});
2304
2421
  for (const m of d.memory_suggestions || []) if (m.kind === 'security')
2305
- out.push({lvl: 1, text: `Credentials pasted in prompts (${m.sessions} sessions): rotate them`, view: 'diagnose', anchor: 'dx-mem'});
2422
+ out.push({lvl: 1, kind: 'fix', key: `sec:${m.sessions}`, view: 'diagnose', anchor: 'dx-mem',
2423
+ text: `Rotate the credentials you pasted into prompts (${m.sessions} session${m.sessions === 1 ? '' : 's'})`,
2424
+ why: 'Closing the sessions does not help: the secrets stay in plain text in those transcripts on disk.'});
2425
+ const byIssue = (sev, lvl) => {
2426
+ const groups = new Map();
2427
+ for (const p of d.projects || []) for (const i of p.issues) if (i.severity === sev) {
2428
+ if (!groups.has(i.title)) groups.set(i.title, []);
2429
+ groups.get(i.title).push({project: p.name, fix: i.fix});
2430
+ }
2431
+ for (const [title, ps] of groups) out.push({lvl, kind: 'fix', view: 'diagnose', anchor: 'dx-issues',
2432
+ key: `proj:${title}:${ps.map(x => x.project).sort().join(',')}`,
2433
+ text: ps.length === 1 ? `${ps[0].project}: ${title}` : `${title} in ${ps.length} projects: ${ps.map(x => x.project).join(', ')}`,
2434
+ why: ps.length === 1 ? ps[0].fix : sameFix(ps.map(x => x.fix))
2435
+ || ps.slice(0, 3).map(x => `${x.project}: ${firstSentence(x.fix)}`).join(' · ')});
2436
+ };
2437
+ byIssue('high', 1);
2306
2438
  for (const r of d.recommendations || []) if (r.priority === 1)
2307
- out.push({lvl: 1, text: r.title, view: 'diagnose', anchor: 'dx-recs'});
2308
- for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'high')
2309
- out.push({lvl: 1, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
2310
- const s0 = (d.session_health || [])[0];
2311
- if (s0) out.push({lvl: 2, text: `Heaviest session: ${s0.title || shortId(s0.session_id)} (peak ${fmtNum(s0.peak)})`, view: 'diagnose', anchor: 'dx-past'});
2312
- for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'medium')
2313
- out.push({lvl: 2, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
2439
+ out.push({lvl: 2, kind: 'habit', key: `rec:${r.title}`, view: 'diagnose', anchor: 'dx-recs',
2440
+ text: r.title, why: `${firstSentence(r.why)} ${firstSentence(r.how)}`});
2441
+ byIssue('medium', 3);
2314
2442
  return out.sort((a, b) => a.lvl - b.lvl);
2315
2443
  }
2444
+ const FOCUS_KEY = 'finops.focus.hidden';
2445
+ const focusHidden = () => { try { return new Set(JSON.parse(localStorage.getItem(FOCUS_KEY) || '[]')); } catch { return new Set(); } };
2446
+ const setFocusHidden = set => { try { localStorage.setItem(FOCUS_KEY, JSON.stringify([...set])); } catch {} };
2316
2447
  function focusStrip(d, n = 5) {
2317
- const items = focusItems(d);
2318
- if (!items.length) return '';
2319
- return `<section class="focus"><div class="focus-hd">🎯 Focus on these first
2320
- <span class="note">${items.filter(i => i.lvl === 1).length} urgent · ${items.filter(i => i.lvl === 2).length} next</span></div>
2321
- ${items.slice(0, n).map((it, k) => `<a class="focus-it l${it.lvl}" data-go="${it.view}" data-anchor="${it.anchor}">
2322
- <span class="fn">${k + 1}</span><span class="ft">${esc(it.text)}</span>
2323
- <span class="fl">${it.lvl === 1 ? 'Fix first' : 'Next'}</span></a>`).join('')}</section>`;
2448
+ const all = focusItems(d), hidden = focusHidden();
2449
+ const items = all.filter(i => !hidden.has(i.key));
2450
+ const nHidden = all.length - items.length;
2451
+ if (!all.length) return '';
2452
+ const fixes = items.filter(i => i.kind === 'fix').length, habits = items.filter(i => i.kind === 'habit').length;
2453
+ return `<section class="focus"><div class="focus-hd">🎯 What to change
2454
+ <span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
2455
+ <span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
2456
+ ${items.slice(0, n).map((it, k) => `<div class="focus-it l${it.lvl === 1 ? 1 : 2}" data-go="${it.view}" data-anchor="${it.anchor}" data-key="${esc(it.key)}">
2457
+ <span class="fn">${k + 1}</span>
2458
+ <span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
2459
+ <span class="fl">${it.kind === 'habit' ? 'Habit' : 'Fix once'}</span>
2460
+ <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">✕</button></div>`).join('')
2461
+ || '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
2324
2462
  }
2325
2463
  function wireFocus(root) {
2326
- root.querySelectorAll('.focus-it').forEach(a => a.onclick = async () => {
2327
- if (S.view !== a.dataset.go) { go(a.dataset.go); await new Promise(r => setTimeout(r, 400)); }
2328
- const el = document.getElementById(a.dataset.anchor);
2329
- if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
2330
- setTimeout(() => el.classList.remove('flash'), 1600); }
2464
+ const redraw = () => { const old = root.querySelector('.focus'); if (!old || !root._focusData) return;
2465
+ const el = h(focusStrip(root._focusData) || '<div></div>'); old.replaceWith(el); wireFocus(root); };
2466
+ root.querySelectorAll('.focus-it').forEach(a => {
2467
+ a.onclick = () => jumpTo(a.dataset.go, a.dataset.anchor);
2468
+ a.querySelector('.fd').onclick = e => { e.stopPropagation();
2469
+ const hid = focusHidden(); hid.add(a.dataset.key); setFocusHidden(hid); redraw(); };
2331
2470
  });
2471
+ const un = root.querySelector('[data-unhide]');
2472
+ if (un) un.onclick = () => { setFocusHidden(new Set()); redraw(); };
2332
2473
  }
2333
2474
  async function updateNavBadges() {
2334
2475
  const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
2335
2476
  if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
2336
2477
  try {
2337
2478
  const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
2338
- if (d) { const f = focusItems(d), urgent = f.filter(i => i.lvl === 1).length;
2479
+ if (d) { const hid = focusHidden(), f = focusItems(d).filter(i => !hid.has(i.key)), urgent = f.filter(i => i.lvl === 1).length;
2339
2480
  set('diagnose', urgent || f.length, urgent ? 1 : 2); }
2340
2481
  const hw = w.findings.filter(x => x.severity === 'high').length;
2341
2482
  set('waste', hw || w.findings.length, hw ? 1 : 2);
package/web/styles.css CHANGED
@@ -331,6 +331,17 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
331
331
  .nav a .nb.l1 { background: var(--critical); color: #fff; }
332
332
  .nav a .nb.l2 { background: var(--warning); color: #3a2a00; }
333
333
 
334
+ .actnow { border: 1px solid color-mix(in srgb, var(--s1) 40%, var(--border));
335
+ background: color-mix(in srgb, var(--s1) 5%, var(--surface)); border-radius: var(--radius);
336
+ padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 6px; }
337
+ .actnow-it { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 8px;
338
+ border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
339
+ .actnow-it .ai { width: 22px; text-align: center; font-size: 15px; }
340
+ .actnow-it .ft { flex: 1; min-width: 220px; }
341
+ .actnow-it .live-actions { margin-top: 0; }
342
+ .row-acts { white-space: nowrap; }
343
+ .row-acts .act { padding: 2px 8px; font-size: 11px; }
344
+
334
345
  .focus { border: 1px solid color-mix(in srgb, var(--critical) 35%, var(--border));
335
346
  background: color-mix(in srgb, var(--critical) 5%, var(--surface)); border-radius: var(--radius);
336
347
  padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
@@ -341,7 +352,12 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
341
352
  .focus-it .fn { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center;
342
353
  font-size: 11px; font-weight: 700; color: #fff; flex: none; }
343
354
  .focus-it.l1 .fn { background: var(--critical); } .focus-it.l2 .fn { background: var(--warning); }
344
- .focus-it .ft { flex: 1; min-width: 0; }
355
+ .focus-it .ft { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
356
+ .focus-it .fx { color: var(--s1); }
357
+ .focus-it .fw { font-size: 11.5px; color: var(--muted); }
358
+ .focus-it .fd { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 12px;
359
+ padding: 2px 6px; border-radius: 6px; }
360
+ .focus-it .fd:hover { background: var(--surface-2); color: var(--text); }
345
361
  .focus-it .fl { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
346
362
  .focus-it.l1 .fl { color: var(--critical-ink); } .focus-it.l2 .fl { color: var(--warning-ink); }
347
363