claude-finops 0.8.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
 
@@ -2,8 +2,8 @@
2
2
  "_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
3
3
  "currency": "USD",
4
4
  "unit": "per_million_tokens",
5
- "updated": "2026-09-23",
6
- "source": "Anthropic list prices (Claude API reference, cached 2026-06-24)",
5
+ "updated": "2026-09-28",
6
+ "source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
7
7
  "models": {
8
8
  "claude-opus-5": {
9
9
  "display_name": "Claude Opus 5",
@@ -84,6 +84,128 @@
84
84
  "context_window": 1000000,
85
85
  "provider": "anthropic"
86
86
  },
87
+ "claude-opus-4-8": {
88
+ "display_name": "Claude Opus 4.8",
89
+ "tier": "frontier",
90
+ "input": 5.0,
91
+ "output": 25.0,
92
+ "cache_write_5m": 6.25,
93
+ "cache_write_1h": 10.0,
94
+ "cache_read": 0.5,
95
+ "context_window": 1000000,
96
+ "provider": "anthropic"
97
+ },
98
+ "claude-opus-4-8[fast]": {
99
+ "display_name": "Claude Opus 4.8 (fast mode)",
100
+ "tier": "frontier",
101
+ "input": 10.0,
102
+ "output": 50.0,
103
+ "cache_write_5m": 12.5,
104
+ "cache_write_1h": 20.0,
105
+ "cache_read": 1.0,
106
+ "context_window": 1000000,
107
+ "provider": "anthropic",
108
+ "_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
109
+ },
110
+ "claude-opus-4-7": {
111
+ "display_name": "Claude Opus 4.7",
112
+ "tier": "frontier",
113
+ "input": 5.0,
114
+ "output": 25.0,
115
+ "cache_write_5m": 6.25,
116
+ "cache_write_1h": 10.0,
117
+ "cache_read": 0.5,
118
+ "context_window": 1000000,
119
+ "provider": "anthropic"
120
+ },
121
+ "claude-opus-4-6": {
122
+ "display_name": "Claude Opus 4.6",
123
+ "tier": "frontier",
124
+ "input": 5.0,
125
+ "output": 25.0,
126
+ "cache_write_5m": 6.25,
127
+ "cache_write_1h": 10.0,
128
+ "cache_read": 0.5,
129
+ "context_window": 1000000,
130
+ "provider": "anthropic"
131
+ },
132
+ "claude-opus-4-5": {
133
+ "display_name": "Claude Opus 4.5",
134
+ "tier": "frontier",
135
+ "input": 5.0,
136
+ "output": 25.0,
137
+ "cache_write_5m": 6.25,
138
+ "cache_write_1h": 10.0,
139
+ "cache_read": 0.5,
140
+ "context_window": 200000,
141
+ "provider": "anthropic"
142
+ },
143
+ "claude-opus-4-1": {
144
+ "display_name": "Claude Opus 4.1",
145
+ "tier": "frontier",
146
+ "input": 15.0,
147
+ "output": 75.0,
148
+ "cache_write_5m": 18.75,
149
+ "cache_write_1h": 30.0,
150
+ "cache_read": 1.5,
151
+ "context_window": 200000,
152
+ "provider": "anthropic"
153
+ },
154
+ "claude-opus-4": {
155
+ "display_name": "Claude Opus 4",
156
+ "tier": "frontier",
157
+ "input": 15.0,
158
+ "output": 75.0,
159
+ "cache_write_5m": 18.75,
160
+ "cache_write_1h": 30.0,
161
+ "cache_read": 1.5,
162
+ "context_window": 200000,
163
+ "provider": "anthropic"
164
+ },
165
+ "claude-sonnet-4-6": {
166
+ "display_name": "Claude Sonnet 4.6",
167
+ "tier": "balanced",
168
+ "input": 3.0,
169
+ "output": 15.0,
170
+ "cache_write_5m": 3.75,
171
+ "cache_write_1h": 6.0,
172
+ "cache_read": 0.3,
173
+ "context_window": 1000000,
174
+ "provider": "anthropic"
175
+ },
176
+ "claude-sonnet-4-5": {
177
+ "display_name": "Claude Sonnet 4.5",
178
+ "tier": "balanced",
179
+ "input": 3.0,
180
+ "output": 15.0,
181
+ "cache_write_5m": 3.75,
182
+ "cache_write_1h": 6.0,
183
+ "cache_read": 0.3,
184
+ "context_window": 200000,
185
+ "provider": "anthropic"
186
+ },
187
+ "claude-sonnet-4": {
188
+ "display_name": "Claude Sonnet 4",
189
+ "tier": "balanced",
190
+ "input": 3.0,
191
+ "output": 15.0,
192
+ "cache_write_5m": 3.75,
193
+ "cache_write_1h": 6.0,
194
+ "cache_read": 0.3,
195
+ "context_window": 200000,
196
+ "provider": "anthropic"
197
+ },
198
+ "claude-3-5-haiku": {
199
+ "display_name": "Claude Haiku 3.5",
200
+ "tier": "economy",
201
+ "input": 0.8,
202
+ "output": 4.0,
203
+ "cache_write_5m": 1.0,
204
+ "cache_write_1h": 1.6,
205
+ "cache_read": 0.08,
206
+ "context_window": 200000,
207
+ "provider": "anthropic"
208
+ },
87
209
  "claude-haiku-4-5-20251001": {
88
210
  "display_name": "Claude Haiku 4.5",
89
211
  "tier": "economy",
@@ -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"))
package/finops/agents.py CHANGED
@@ -33,7 +33,8 @@ def _cursor_state_db():
33
33
 
34
34
  AGENTS = {
35
35
  "claude": {"name": "Claude Code", "data": "full",
36
- "note": "Tokens, model, cost, prompts and tool calls per request."},
36
+ "note": "Tokens, model, cost, prompts and tool calls per request. "
37
+ "Includes Claude desktop app (Cowork) sessions when present."},
37
38
  "codex": {"name": "Codex", "data": "tokens",
38
39
  "paths": [os.path.join(HOME, ".codex", "sessions")],
39
40
  "note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
@@ -12,6 +12,7 @@ import sqlite3
12
12
  import statistics
13
13
  import sys
14
14
  import threading
15
+ import time
15
16
  from collections import defaultdict
16
17
  from datetime import date, datetime, timedelta, timezone
17
18
 
@@ -167,6 +168,16 @@ def _cumsum(values):
167
168
  yield total
168
169
 
169
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
+
170
181
  class Analytics:
171
182
  def __init__(self, db_path=DB_PATH):
172
183
  # One connection per thread. The HTTP server is threaded, and a single sqlite
@@ -334,6 +345,7 @@ class Analytics:
334
345
  f" FROM requests r WHERE {w} AND {extra}", p + ep)
335
346
 
336
347
  tot["cost_today"] = spend("r.day = ?", [today])
348
+ tot["cost_yesterday"] = spend("r.day = ?", [(_d(today) - timedelta(days=1)).isoformat()])
337
349
  tot["cost_week"] = spend("r.day >= ?", [wk])
338
350
  tot["cost_period"] = spend("r.day >= ? AND r.day <= ?", [bp["start"], bp["end"]])
339
351
  tot["billing_period"] = bp
@@ -443,6 +455,30 @@ class Analytics:
443
455
  AVG(r.context_tokens) avg_context
444
456
  FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
445
457
 
458
+ def heatmap(self, f=None):
459
+ """Spend by weekday x hour, in this machine's local time (Monday first).
460
+
461
+ Transcripts stamp UTC; bucketing on that would put a 10am IST session at
462
+ 4am. SQLite's 'localtime' modifier applies the local offset, half-hour
463
+ zones included. Date filters still apply to UTC days like every view.
464
+ """
465
+ w, p = self.where(f)
466
+ rows = self.q(f"""
467
+ SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
468
+ CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
469
+ COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
470
+ COUNT(*) requests
471
+ FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
472
+ grid = {(d, h): {"dow": d, "hour": h, "cost": 0.0, "tokens": 0, "requests": 0}
473
+ for d in range(7) for h in range(24)}
474
+ for r in rows:
475
+ if r["wd"] is None or r["hr"] is None:
476
+ continue
477
+ c = grid[((r["wd"] + 6) % 7, r["hr"])]
478
+ c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
479
+ return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
480
+ "tz": time.strftime("%Z"), "cost_basis": "estimated"}
481
+
446
482
  # ---------------- models ----------------
447
483
  def models(self, f=None):
448
484
  w, p = self.where(f)
@@ -535,6 +571,7 @@ class Analytics:
535
571
  rows = self.q(f"""
536
572
  SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
537
573
  s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
574
+ s.agent, s.source_file,
538
575
  COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
539
576
  SUM(r.tool_call_count) tool_calls,
540
577
  SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
@@ -553,6 +590,7 @@ class Analytics:
553
590
  r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
554
591
  cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
555
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"))
556
594
  return rows
557
595
 
558
596
  def sessions_total(self, f=None):
@@ -673,6 +711,7 @@ class Analytics:
673
711
  s["files"] = self.q(
674
712
  "SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
675
713
  " WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
714
+ s["resume"] = resume_command(sid, s.get("agent"), s.get("source_file"))
676
715
  return s
677
716
 
678
717
  # ---------------- categories ----------------
@@ -694,7 +733,7 @@ class Analytics:
694
733
 
695
734
  # ---------------- leaderboards ----------------
696
735
  def leaderboards(self, f=None, n=20):
697
- return {
736
+ out = {
698
737
  "most_expensive": self.prompts(f, limit=n, order="cost"),
699
738
  "most_token_heavy": self.prompts(f, limit=n, order="tokens"),
700
739
  "cheapest": self.prompts(f, limit=n, order="cheapest"),
@@ -702,6 +741,17 @@ class Analytics:
702
741
  "longest_sessions": self.sessions(f, limit=n, order="duration"),
703
742
  "basis": "estimated",
704
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
705
755
 
706
756
  # ---------------- efficiency ----------------
707
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)
@@ -366,6 +369,9 @@ class Handler(BaseHTTPRequestHandler):
366
369
  if route == "usage":
367
370
  from .limits import usage
368
371
  return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
372
+ if route == "plan_history":
373
+ from .plan_history import history
374
+ return self.send_json(history())
369
375
  if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
370
376
  return self.actions_get(route)
371
377
  f = filters_from(qs)
@@ -385,6 +391,11 @@ class Handler(BaseHTTPRequestHandler):
385
391
  return self.send_json(a.burn(f))
386
392
  if route == "timeline":
387
393
  return self.send_json(a.timeline(f, g("grain", "day")))
394
+ if route == "heatmap":
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))
388
399
  if route == "models":
389
400
  return self.send_json(a.models(f))
390
401
  if route == "projects":
package/finops/etl.py CHANGED
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
16
16
  from .classify import classify
17
17
  from .pricing import Pricing
18
18
 
19
- from .paths import ROOT, DB_PATH
19
+ from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
20
20
  DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
21
21
 
22
22
  SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
@@ -203,8 +203,14 @@ def _slug_to_name(slug):
203
203
 
204
204
 
205
205
  class Loader:
206
- def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True):
206
+ def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
207
+ desktop_roots=None):
207
208
  self.other_agents = other_agents
209
+ # An explicit source means "load exactly this"; only the default also picks up
210
+ # the desktop app's Cowork sessions (same JSONL format, different config dir).
211
+ self.desktop_roots = (desktop_roots if desktop_roots is not None
212
+ else [DESKTOP_SESSIONS] if source == DEFAULT_SOURCE else [])
213
+ self.cowork = False
208
214
  self.db_path = db_path
209
215
  self.source = source
210
216
  self.pricing = pricing or Pricing()
@@ -232,13 +238,19 @@ class Loader:
232
238
  if n.endswith(".jsonl"):
233
239
  files.append(os.path.join(dirpath, n))
234
240
  files.sort()
235
- for i, fp in enumerate(files, 1):
241
+ marker = os.sep + os.path.join(".claude", "projects") + os.sep
242
+ desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
243
+ for d, _, names in os.walk(root) for n in names
244
+ if n.endswith(".jsonl") and marker in d + os.sep)
245
+ for i, fp in enumerate(files + desktop, 1):
236
246
  if verbose and i % 20 == 0:
237
- print(f" ...{i}/{len(files)} transcripts", file=sys.stderr)
247
+ print(f" ...{i}/{len(files) + len(desktop)} transcripts", file=sys.stderr)
248
+ self.cowork = i > len(files)
238
249
  try:
239
250
  self.load_file(fp)
240
251
  except Exception as exc: # a corrupt transcript must not kill the load
241
252
  print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
253
+ self.cowork = False
242
254
  if self.other_agents:
243
255
  from .agents import AgentLoader
244
256
  counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
@@ -253,6 +265,7 @@ class Loader:
253
265
  for k, v in (
254
266
  ("source_dir", self.source),
255
267
  ("transcript_files", str(len(files))),
268
+ ("desktop_transcript_files", str(len(desktop))),
256
269
  ("pricing_updated", str(self.pricing.updated)),
257
270
  ("pricing_source", str(self.pricing.source)),
258
271
  ("cost_basis", "estimated"),
@@ -260,7 +273,7 @@ class Loader:
260
273
  ):
261
274
  self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
262
275
  self.db.commit()
263
- return len(files)
276
+ return len(files) + len(desktop)
264
277
 
265
278
  def project_id(self, slug, cwd):
266
279
  if slug in self.projects:
@@ -271,6 +284,8 @@ class Loader:
271
284
  return pid
272
285
  is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
273
286
  name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
287
+ if self.cowork:
288
+ name = f"Cowork · {name or 'session'}"
274
289
  cur = self.db.execute(
275
290
  "INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
276
291
  (slug, cwd, name or slug, is_sandbox))
@@ -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/finops/paths.py CHANGED
@@ -109,3 +109,15 @@ def migrate(log=print):
109
109
  with open(_MARKER, "w") as fh:
110
110
  fh.write("state dir in use; delete this file to re-run migration\n")
111
111
  return bool(moved)
112
+
113
+
114
+ # The Claude desktop app's data dir. Read-only: Cowork transcripts and the plan
115
+ # usage history live here.
116
+ if os.name == "nt":
117
+ DESKTOP_DIR = os.path.join(os.environ.get("APPDATA", ""), "Claude")
118
+ elif os.uname().sysname == "Darwin":
119
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "Claude")
120
+ else:
121
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), ".config", "Claude")
122
+ DESKTOP_SESSIONS = os.path.join(DESKTOP_DIR, "local-agent-mode-sessions")
123
+ PLAN_HISTORY_PATH = os.path.join(DESKTOP_DIR, "plan-usage-history.json")
@@ -0,0 +1,80 @@
1
+ """Plan-limit history, as the Claude desktop app records it.
2
+
3
+ The desktop app samples plan usage every few minutes into plan-usage-history.json:
4
+ `fh` is the 5-hour window and `sd` the weekly (seven-day) window, both percent used.
5
+ The format is undocumented, so anything unexpected returns {"ok": False, "reason"}
6
+ instead of raising. Read on request, cached on the file's mtime.
7
+ """
8
+ import json
9
+ import os
10
+ from datetime import datetime, timezone
11
+
12
+ from .paths import PLAN_HISTORY_PATH
13
+
14
+ KNOWN_VERSIONS = {2}
15
+ _cache = {}
16
+
17
+
18
+ def _num(v):
19
+ return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
20
+
21
+
22
+ def _runs(values, threshold):
23
+ """Separate stretches at or above the threshold, not raw sample counts."""
24
+ n, above = 0, False
25
+ for v in values:
26
+ hit = v is not None and v >= threshold
27
+ n += hit and not above
28
+ above = hit
29
+ return n
30
+
31
+
32
+ def _peak(values):
33
+ return max((v for v in values if v is not None), default=None)
34
+
35
+
36
+ def history(path=None):
37
+ path = path or PLAN_HISTORY_PATH
38
+ try:
39
+ mtime = os.path.getmtime(path)
40
+ except OSError:
41
+ return {"ok": False, "reason": "No plan history on this machine. The Claude desktop "
42
+ "app records it; it is not installed or has not saved any yet."}
43
+ hit = _cache.get(path)
44
+ if hit and hit[0] == mtime:
45
+ return hit[1]
46
+ res = _read(path)
47
+ _cache[path] = (mtime, res)
48
+ return res
49
+
50
+
51
+ def _read(path):
52
+ try:
53
+ with open(path) as fh:
54
+ raw = json.load(fh)
55
+ except (OSError, ValueError):
56
+ return {"ok": False, "reason": "The plan history file could not be read."}
57
+ version = raw.get("version") if isinstance(raw, dict) else None
58
+ if version not in KNOWN_VERSIONS:
59
+ return {"ok": False, "reason": f"Unrecognised plan history format (version {version})."}
60
+ samples = [x for x in raw.get("samples") or []
61
+ if isinstance(x, dict) and _num(x.get("t")) is not None]
62
+ if not samples:
63
+ return {"ok": False, "reason": "The plan history file has no samples yet."}
64
+ samples.sort(key=lambda x: x["t"])
65
+ orgs = {x.get("org") for x in samples}
66
+ org = samples[-1].get("org")
67
+ series = []
68
+ for x in samples:
69
+ if x.get("org") != org:
70
+ continue
71
+ u = x.get("u") if isinstance(x.get("u"), dict) else {}
72
+ t = datetime.fromtimestamp(x["t"] / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
73
+ series.append({"t": t, "five_hour": _num(u.get("fh")), "weekly": _num(u.get("sd"))})
74
+ fh = [r["five_hour"] for r in series]
75
+ sd = [r["weekly"] for r in series]
76
+ return {"ok": True, "source": path, "org_count": len(orgs), "series": series,
77
+ "summary": {"five_hour_peak": _peak(fh), "weekly_peak": _peak(sd),
78
+ "five_hour_ge90": _runs(fh, 90), "five_hour_hit100": _runs(fh, 100),
79
+ "weekly_ge90": _runs(sd, 90), "weekly_hit100": _runs(sd, 100),
80
+ "first": series[0]["t"], "last": series[-1]["t"]}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.8.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
@@ -73,6 +73,13 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
73
73
  const shortDay = d => (d || '').slice(5);
74
74
  const shortId = s => (s || '').slice(0, 8);
75
75
 
76
+ // "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
77
+ const vsYesterday = o => {
78
+ const y = o.cost_yesterday?.c, t = o.cost_today?.c;
79
+ if (!y || t == null) return '';
80
+ const d = (t - y) / y * 100;
81
+ return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
82
+ };
76
83
  function kpi(label, value, detail, opts = {}) {
77
84
  const na = value == null;
78
85
  return `<div class="kpi${na ? ' na' : ''}">
@@ -452,6 +459,8 @@ async function openSession(id) {
452
459
  : '<div class="na">None</div>')}
453
460
  </div>
454
461
  ${card('Session metadata', `<dl class="kv">
462
+ ${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
463
+ <button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
455
464
  <dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
456
465
  <dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
457
466
  <dt>Project</dt><dd>${esc(s.project)}</dd>
@@ -463,6 +472,10 @@ async function openSession(id) {
463
472
  <dt>Commits / PRs</dt><dd>${NA()}</dd>
464
473
  </dl>`)}`;
465
474
  wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
475
+ const rb = cont.querySelector('#sess-resume');
476
+ if (rb) rb.onclick = async () => {
477
+ try { await navigator.clipboard.writeText(rb.dataset.copy); rb.textContent = 'Copied'; }
478
+ catch { rb.textContent = 'Copy failed'; } };
466
479
  const tl = s.timeline.map((r, i) => ({i, ...r}));
467
480
  C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
468
481
  series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
@@ -500,7 +513,7 @@ VIEWS.overview = async (page) => {
500
513
  page.innerHTML = `
501
514
  <div class="grid g5">
502
515
  ${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
503
- `${fmtUSD(o.cost_today.c)} today · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
516
+ `${fmtUSD(o.cost_today.c)} today${vsYesterday(o)} · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
504
517
  ${kpi('Billable tokens', fmtNum(o.billable_tokens),
505
518
  `${fmtNum(o.output_tokens)} output · ${fmtNum(o.cache_read_tokens)} cache read`,
506
519
  {badge: BADGE.actual})}
@@ -672,10 +685,110 @@ VIEWS.overview = async (page) => {
672
685
  api('diagnose').then(d => {
673
686
  if (!page.isConnected) return;
674
687
  const el = h(focusStrip(d) || '<div></div>');
675
- page.insertBefore(el, page.firstChild);
688
+ page.insertBefore(el, page.querySelector('.actnow')?.nextSibling || page.firstChild);
689
+ page._focusData = d;
676
690
  wireFocus(page);
677
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>`;
678
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
+ }
679
792
 
680
793
  function renderAdvisorHero(page, advisor) {
681
794
  if (!page || !advisor || !Array.isArray(advisor.actions)) return;
@@ -763,9 +876,10 @@ const METRICS = [
763
876
  ['avg_context', 'Avg context', fmtNum],
764
877
  ];
765
878
  VIEWS.usage = async (page) => {
766
- const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
767
- api('models'), api('overview')]);
879
+ const [tl, models, ov, hm] = await Promise.all([api('timeline', `&grain=${S.grain}`),
880
+ api('models'), api('overview'), api('heatmap')]);
768
881
  const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
882
+ const hmMetric = ['cost', 'tokens', 'requests'].includes(S.metric) ? S.metric : 'cost';
769
883
  page.innerHTML = `
770
884
  <div class="grid g5">
771
885
  ${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
@@ -793,6 +907,9 @@ VIEWS.usage = async (page) => {
793
907
  footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
794
908
  + 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
795
909
  flush: 0})}
910
+ ${card('Peak hours', '<div class="chart" id="heat"></div>',
911
+ {badge: hmMetric === 'cost' ? BADGE.estimated : BADGE.actual,
912
+ hint: `your local time (${hm.tz || 'local'}) · ${hmMetric === 'cost' ? 'estimated cost' : hmMetric}`})}
796
913
  ${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
797
914
  '<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
798
915
  hint: 'stacked estimated cost per model'})}
@@ -810,6 +927,7 @@ VIEWS.usage = async (page) => {
810
927
  xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
811
928
  onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
812
929
  S.range = 'custom'; bust(); render(); } }});
930
+ C.heatmap($('#heat', page), {cells: hm.cells, metric: hmMetric});
813
931
 
814
932
  // per-model stacked mix
815
933
  const byDay = new Map();
@@ -849,7 +967,8 @@ VIEWS.usage = async (page) => {
849
967
 
850
968
  /* ---------- burn & limits ---------- */
851
969
  VIEWS.burn = async (page) => {
852
- const burn = await api('burn');
970
+ const [burn, plan] = await Promise.all([api('burn'), fetch('/api/plan_history')
971
+ .then(r => r.json()).catch(e => ({ok: false, reason: e.message}))]);
853
972
  const bp = burn.period;
854
973
  const rows = Object.entries(burn.allowances);
855
974
  page.innerHTML = `
@@ -901,10 +1020,31 @@ VIEWS.burn = async (page) => {
901
1020
  <span class="mono">config/settings.json → limits</span> to enable usage-vs-limit,
902
1021
  days-until-limit and limit-date projections.</p></div>`,
903
1022
  {badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
1023
+ ${card('Plan limits over time', plan.ok ? `
1024
+ <div class="grid g3">
1025
+ ${kpi('5-hour peak', fmtPct(plan.summary.five_hour_peak ?? 0, 0), null, {badge: BADGE.actual})}
1026
+ ${kpi('Times at 90%+ (5-hour)', fmtInt(plan.summary.five_hour_ge90),
1027
+ `${fmtInt(plan.summary.five_hour_hit100)} reached 100%`)}
1028
+ ${kpi('Weekly peak', fmtPct(plan.summary.weekly_peak ?? 0, 0),
1029
+ `${fmtInt(plan.summary.weekly_ge90)} times at 90%+`)}
1030
+ </div>
1031
+ <div class="legend" id="planleg"></div><div class="chart" id="plan"></div>`
1032
+ : `<div class="empty">${esc(plan.reason || 'Unavailable')}</div>`,
1033
+ {badge: BADGE.actual, hint: '5-hour and weekly plan usage, % used',
1034
+ footer: 'Read from the Claude desktop app\'s local plan-usage-history.json. '
1035
+ + 'Its format is undocumented, so this card may go blank after an app update.'})}
904
1036
  ${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
905
1037
  {badge: BADGE.estimated})}`;
906
1038
  for (const [k, a] of rows) if (a.configured)
907
1039
  C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
1040
+ if (plan.ok) {
1041
+ const ps = [{key: 'five_hour', label: '5-hour window', color: seriesVar(0), fmt: v => fmtPct(v, 0)},
1042
+ {key: 'weekly', label: 'Weekly window', color: seriesVar(1), fmt: v => fmtPct(v, 0)}];
1043
+ C.legend($('#planleg', page), ps.map(s => ({label: s.label, color: s.color})));
1044
+ C.timeSeries($('#plan', page), {rows: plan.series, x: 't', type: 'line', series: ps,
1045
+ fmt: v => fmtPct(v, 0), max: 100, height: 220,
1046
+ xLabel: t => new Date(t).toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})});
1047
+ }
908
1048
  C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
909
1049
  series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
910
1050
  fmt: fmtUSD, height: 220, xLabel: shortDay});
@@ -1036,7 +1176,7 @@ VIEWS.sessions = async (page) => {
1036
1176
  const sp = S.sessPage;
1037
1177
  const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
1038
1178
  const rows = res.rows, total = res.total;
1039
- const eff = await api('efficiency');
1179
+ const [eff, live] = await Promise.all([api('efficiency'), liveSessions()]);
1040
1180
  const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
1041
1181
  page.innerHTML = `
1042
1182
  <div class="grid g4">
@@ -1081,6 +1221,7 @@ VIEWS.sessions = async (page) => {
1081
1221
  {h: 'Session', trunc: 1, title: r => r.session_id,
1082
1222
  f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
1083
1223
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1224
+ {h: '', f: r => rowActs(r, live)},
1084
1225
  {h: 'Project', f: r => esc(r.project)},
1085
1226
  {h: 'Branch', f: r => r.git_branch ? `<span class="mono">${esc(r.git_branch)}</span>` : '—'},
1086
1227
  {h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
@@ -1101,6 +1242,7 @@ VIEWS.sessions = async (page) => {
1101
1242
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
1102
1243
  ], rows, {onRow: 1});
1103
1244
  wireTable($('#st', page), rows, r => openSession(r.session_id));
1245
+ wireRowActs($('#st', page));
1104
1246
  const effCards = [...page.querySelectorAll('.card')].filter(c =>
1105
1247
  /output yield/.test(c.querySelector('h3')?.textContent || ''));
1106
1248
  wireTable(effCards[0], eff.low_efficiency_sessions, r => openSession(r.session_id));
@@ -1179,7 +1321,7 @@ const RANK_TABS = [
1179
1321
  ['cheapest', 'Top 20 cheapest'], ['most_efficient', 'Top 20 most efficient'],
1180
1322
  ['longest_sessions', 'Top 20 longest sessions']];
1181
1323
  VIEWS.rankings = async (page) => {
1182
- const lb = await api('leaderboards', '&n=20');
1324
+ const [lb, live] = await Promise.all([api('leaderboards', '&n=20'), liveSessions()]);
1183
1325
  const tab = S.rankTab || 'most_expensive';
1184
1326
  const isSess = tab === 'longest_sessions';
1185
1327
  const rows = lb[tab];
@@ -1193,6 +1335,7 @@ VIEWS.rankings = async (page) => {
1193
1335
  const cols = isSess ? [
1194
1336
  {h: '#', num: 1, f: r => rows.indexOf(r) + 1},
1195
1337
  {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1338
+ {h: '', f: r => rowActs(r, live)},
1196
1339
  {h: 'Project', f: r => esc(r.project)},
1197
1340
  {h: 'Duration', num: 1, f: r => dur(r.duration_s)},
1198
1341
  {h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
@@ -1211,10 +1354,12 @@ VIEWS.rankings = async (page) => {
1211
1354
  {h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
1212
1355
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
1213
1356
  {h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
1357
+ {h: '', f: r => rowActs(r, live)},
1214
1358
  {h: 'Date', f: r => esc(r.day)},
1215
1359
  ];
1216
1360
  $('#rt', page).innerHTML = table(cols, rows, {onRow: 1});
1217
1361
  wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
1362
+ wireRowActs($('#rt', page));
1218
1363
  addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
1219
1364
  rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
1220
1365
  sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
@@ -2257,45 +2402,81 @@ VIEWS.exports = async (page) => {
2257
2402
  /* ============================ focus cues ============================ */
2258
2403
  // "What needs me first": one ranked list built from the diagnosis, used for the nav
2259
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
+ }
2260
2419
  function focusItems(d) {
2261
2420
  const out = [];
2262
- for (const x of d.live_sessions || []) if (x.severity !== 'ok')
2263
- 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'});
2264
2421
  for (const m of d.memory_suggestions || []) if (m.kind === 'security')
2265
- 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);
2266
2438
  for (const r of d.recommendations || []) if (r.priority === 1)
2267
- out.push({lvl: 1, text: r.title, view: 'diagnose', anchor: 'dx-recs'});
2268
- for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'high')
2269
- out.push({lvl: 1, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
2270
- const s0 = (d.session_health || [])[0];
2271
- if (s0) out.push({lvl: 2, text: `Heaviest session: ${s0.title || shortId(s0.session_id)} (peak ${fmtNum(s0.peak)})`, view: 'diagnose', anchor: 'dx-past'});
2272
- for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'medium')
2273
- 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);
2274
2442
  return out.sort((a, b) => a.lvl - b.lvl);
2275
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 {} };
2276
2447
  function focusStrip(d, n = 5) {
2277
- const items = focusItems(d);
2278
- if (!items.length) return '';
2279
- return `<section class="focus"><div class="focus-hd">🎯 Focus on these first
2280
- <span class="note">${items.filter(i => i.lvl === 1).length} urgent · ${items.filter(i => i.lvl === 2).length} next</span></div>
2281
- ${items.slice(0, n).map((it, k) => `<a class="focus-it l${it.lvl}" data-go="${it.view}" data-anchor="${it.anchor}">
2282
- <span class="fn">${k + 1}</span><span class="ft">${esc(it.text)}</span>
2283
- <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>`;
2284
2462
  }
2285
2463
  function wireFocus(root) {
2286
- root.querySelectorAll('.focus-it').forEach(a => a.onclick = async () => {
2287
- if (S.view !== a.dataset.go) { go(a.dataset.go); await new Promise(r => setTimeout(r, 400)); }
2288
- const el = document.getElementById(a.dataset.anchor);
2289
- if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
2290
- 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(); };
2291
2470
  });
2471
+ const un = root.querySelector('[data-unhide]');
2472
+ if (un) un.onclick = () => { setFocusHidden(new Set()); redraw(); };
2292
2473
  }
2293
2474
  async function updateNavBadges() {
2294
2475
  const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
2295
2476
  if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
2296
2477
  try {
2297
2478
  const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
2298
- 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;
2299
2480
  set('diagnose', urgent || f.length, urgent ? 1 : 2); }
2300
2481
  const hw = w.findings.filter(x => x.severity === 'high').length;
2301
2482
  set('waste', hw || w.findings.length, hw ? 1 : 2);
package/web/charts.js CHANGED
@@ -70,7 +70,7 @@ export function timeSeries(host, opts) {
70
70
  const totals = rows.map(r => stacked
71
71
  ? active.reduce((a, s) => a + (+r[s.key] || 0), 0)
72
72
  : Math.max(...active.map(s => +r[s.key] || 0), 0));
73
- const max = nice(Math.max(...totals, 0) || 1);
73
+ const max = Math.max(opts.max || 0, nice(Math.max(...totals, 0) || 1));
74
74
  const y = v => m.t + ih - (v / max) * ih;
75
75
  const n = rows.length;
76
76
  const bw = Math.max(2, Math.min(34, iw / n * 0.72));
@@ -349,3 +349,36 @@ export function legend(host, items, onToggle) {
349
349
  host.appendChild(s);
350
350
  });
351
351
  }
352
+
353
+ /* ---------- heatmap: weekday x hour ---------- */
354
+ const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
355
+ export function heatmap(host, {cells, metric = 'cost'}) {
356
+ host.innerHTML = '';
357
+ const max = Math.max(0, ...cells.map(c => +c[metric] || 0));
358
+ if (!max) { host.innerHTML = '<div class="empty">No activity in range</div>'; return; }
359
+ const W = Math.max(host.clientWidth || 640, 320);
360
+ const m = {t: 4, r: 4, b: 18, l: 34};
361
+ const cw = (W - m.l - m.r) / 24, ch = Math.min(22, Math.max(12, cw * 0.8));
362
+ const H = m.t + ch * 7 + m.b;
363
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H, role: 'img'});
364
+ DOW.forEach((d, i) => svg.appendChild(el('text', {x: m.l - 6, y: m.t + i * ch + ch * 0.68,
365
+ 'text-anchor': 'end'}, d)));
366
+ for (let h = 0; h < 24; h += 3) svg.appendChild(el('text', {x: m.l + h * cw + cw / 2,
367
+ y: H - 5, 'text-anchor': 'middle'}, String(h).padStart(2, '0')));
368
+ cells.forEach(c => {
369
+ const v = +c[metric] || 0;
370
+ const r = el('rect', {x: m.l + c.hour * cw + 1, y: m.t + c.dow * ch + 1,
371
+ width: Math.max(1, cw - 2), height: Math.max(1, ch - 2), rx: 2,
372
+ fill: v ? 'var(--s1)' : 'var(--grid)',
373
+ 'fill-opacity': v ? (0.15 + 0.85 * Math.sqrt(v / max)).toFixed(3) : 1});
374
+ r.addEventListener('mousemove', ev => showTip(
375
+ `<div class="t">${DOW[c.dow]} ${String(c.hour).padStart(2, '0')}:00</div>
376
+ <div class="row"><span class="k">Est. cost</span><span class="v">${fmtUSD(c.cost)}</span></div>
377
+ <div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(c.tokens)}</span></div>
378
+ <div class="row"><span class="k">Requests</span><span class="v">${fmtInt(c.requests)}</span></div>`,
379
+ ev.clientX, ev.clientY));
380
+ r.addEventListener('mouseleave', hideTip);
381
+ svg.appendChild(r);
382
+ });
383
+ host.appendChild(svg);
384
+ }
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