claude-finops 0.15.1 → 0.17.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
@@ -230,7 +230,7 @@ without it; it only adds the warning and "continue?" prompt inside Claude Code.
230
230
  **Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
231
231
  `~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
232
232
  ever gets back whether a key is set, where from, and its last four characters. An environment
233
- variable (`ANTHROPIC_ADMIN_KEY`, `CURSOR_API_KEY`) takes priority over a stored key.
233
+ variable (`ANTHROPIC_ADMIN_KEY`) takes priority over a stored key.
234
234
 
235
235
  ### Per-session token budget and the session guard
236
236
 
@@ -374,8 +374,7 @@ place for you to delete once you are happy.
374
374
  **What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
375
375
  if the Claude desktop app is installed, its Cowork transcripts and
376
376
  `plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
377
- `%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux), plus the other coding
378
- agents listed under Multi-agent.
377
+ `%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux).
379
378
 
380
379
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
381
380
  server binds to `127.0.0.1` only, but treat the database and any export you
@@ -438,18 +437,24 @@ files on Windows.
438
437
  "Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
439
438
  The strongest skill suggestions also appear in **⚡ Act now** on the overview.
440
439
  - **Compact** (Running sessions, Recent, Act now, session rows) types `/compact` into that session's
441
- terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
440
+ terminal: tmux, herdr, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
442
441
  command for you to paste. It asks you to click twice.
443
442
  - **Hand over / split** (Running sessions) writes a brief of the session to
444
- `~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
445
- you list them; it can close the old session (resumable).
443
+ `~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals (a new herdr tab in the
444
+ same workspace when the session runs in herdr), one per sub-task if you list them; it can close
445
+ the old session (resumable).
446
+ - **Prompts** (Running sessions) shows a session's first prompt and its latest five, so you can
447
+ tell which session is which before you close it.
448
+ - **Rename** (Running sessions) types `/rename <name>` into that session's terminal, so Claude
449
+ Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
450
+ type, the command lands on your clipboard.
446
451
  - **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
447
452
  with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
448
453
  list its prompts, and click a prompt for its full details. Search matches your prompt text.
449
- It covers all dates but keeps the agent and project filters. Close and Force kill stay on
454
+ It covers all dates but keeps the project filters. Close and Force kill stay on
450
455
  Running sessions.
451
- - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
452
- Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
456
+ - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
457
+ (in herdr, Interrupt presses Esc in the session's pane instead). Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
453
458
  interrupt one console process without hitting the others in its window.
454
459
  - **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
455
460
  `~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
@@ -502,23 +507,6 @@ The **Jev (fast decisions)** page (sidebar, Optimize):
502
507
  tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
503
508
  published price ($0.042 per million input tokens). Read-only.
504
509
 
505
- ---
506
-
507
- ## Multi-agent
508
-
509
- Besides Claude Code, the warehouse loads every other coding agent it finds on the machine:
510
-
511
- | Agent | Read from | What it gives |
512
- |---|---|---|
513
- | Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
514
- | Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
515
- | Cursor | Cursor IDE `state.vscdb` (read-only) | Prompts, times and tokens as the IDE stored them; no model, not priced. Agent transcripts are skipped: they carry no times or tokens |
516
-
517
- Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
518
- everything. Every page follows the selection, and **Agents** shows them side by side.
519
- Prices for the other providers live in `config/pricing.json` with their source URLs.
520
-
521
-
522
510
  ---
523
511
 
524
512
  ## License and trademarks
@@ -3,7 +3,7 @@
3
3
  "currency": "USD",
4
4
  "unit": "per_million_tokens",
5
5
  "updated": "2026-10-02",
6
- "source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02); OpenAI (developers.openai.com/api/docs/pricing) and Google (ai.google.dev/gemini-api/docs/pricing) checked 2026-10-02",
6
+ "source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02)",
7
7
  "models": {
8
8
  "claude-opus-5": {
9
9
  "display_name": "Claude Opus 5",
@@ -237,149 +237,9 @@
237
237
  "cache_write_1h": 0.0,
238
238
  "cache_read": 0.0,
239
239
  "context_window": 0
240
- },
241
- "gpt-5.5": {
242
- "display_name": "GPT-5.5 (Codex)",
243
- "tier": "frontier",
244
- "provider": "openai",
245
- "input": 5.0,
246
- "output": 30.0,
247
- "cache_read": 0.5,
248
- "cache_write_5m": 5.0,
249
- "cache_write_1h": 5.0,
250
- "context_window": 400000
251
- },
252
- "gpt-5.3-codex": {
253
- "display_name": "GPT-5.3 Codex",
254
- "tier": "balanced",
255
- "provider": "openai",
256
- "input": 1.75,
257
- "output": 14.0,
258
- "cache_read": 0.175,
259
- "cache_write_5m": 1.75,
260
- "cache_write_1h": 1.75,
261
- "context_window": 400000
262
- },
263
- "gpt-5.2-codex": {
264
- "display_name": "GPT-5.2 Codex",
265
- "tier": "balanced",
266
- "provider": "openai",
267
- "input": 1.75,
268
- "output": 14.0,
269
- "cache_read": 0.175,
270
- "cache_write_5m": 1.75,
271
- "cache_write_1h": 1.75,
272
- "context_window": 400000,
273
- "_note": "Not on OpenAI's price list; priced as GPT-5.2"
274
- },
275
- "gpt-5.2": {
276
- "display_name": "GPT-5.2",
277
- "tier": "balanced",
278
- "provider": "openai",
279
- "input": 1.75,
280
- "output": 14.0,
281
- "cache_read": 0.175,
282
- "cache_write_5m": 1.75,
283
- "cache_write_1h": 1.75,
284
- "context_window": 400000
285
- },
286
- "gpt-5.1": {
287
- "display_name": "GPT-5.1",
288
- "tier": "balanced",
289
- "provider": "openai",
290
- "input": 1.25,
291
- "output": 10.0,
292
- "cache_read": 0.125,
293
- "cache_write_5m": 1.25,
294
- "cache_write_1h": 1.25,
295
- "context_window": 400000
296
- },
297
- "gpt-5": {
298
- "display_name": "GPT-5",
299
- "tier": "balanced",
300
- "provider": "openai",
301
- "input": 1.25,
302
- "output": 10.0,
303
- "cache_read": 0.125,
304
- "cache_write_5m": 1.25,
305
- "cache_write_1h": 1.25,
306
- "context_window": 400000
307
- },
308
- "gpt-5-mini": {
309
- "display_name": "GPT-5 mini",
310
- "tier": "economy",
311
- "provider": "openai",
312
- "input": 0.25,
313
- "output": 2.0,
314
- "cache_read": 0.025,
315
- "cache_write_5m": 0.25,
316
- "cache_write_1h": 0.25,
317
- "context_window": 400000
318
- },
319
- "gemini-2.5-flash": {
320
- "display_name": "Gemini 2.5 Flash",
321
- "tier": "economy",
322
- "provider": "google",
323
- "input": 0.3,
324
- "output": 2.5,
325
- "cache_read": 0.03,
326
- "cache_write_5m": 0.3,
327
- "cache_write_1h": 0.3,
328
- "context_window": 1048576,
329
- "_note": "Cache read: lower bound of Google's published range"
330
- },
331
- "gemini-2.5-pro": {
332
- "display_name": "Gemini 2.5 Pro",
333
- "tier": "frontier",
334
- "provider": "google",
335
- "input": 1.25,
336
- "output": 10.0,
337
- "cache_read": 0.125,
338
- "cache_write_5m": 1.25,
339
- "cache_write_1h": 1.25,
340
- "context_window": 1048576,
341
- "_note": "<=200K prompt; $2.50/$15 above"
342
- },
343
- "gemini-3.5-flash": {
344
- "display_name": "Gemini 3.5 Flash",
345
- "tier": "balanced",
346
- "provider": "google",
347
- "input": 1.5,
348
- "output": 9.0,
349
- "cache_read": 0.15,
350
- "cache_write_5m": 1.5,
351
- "cache_write_1h": 1.5,
352
- "context_window": 1048576
353
- },
354
- "gemini-3.8-flash": {
355
- "display_name": "Gemini 3.8 Flash",
356
- "tier": "economy",
357
- "provider": "google",
358
- "input": 0.75,
359
- "output": 3.75,
360
- "cache_read": 0.075,
361
- "cache_write_5m": 0.75,
362
- "cache_write_1h": 0.75,
363
- "context_window": 1048576,
364
- "_note": "Promo price through 2026-12-31; $1.50/$7.50 after"
365
- },
366
- "cursor": {
367
- "display_name": "Cursor (model not recorded)",
368
- "tier": "none",
369
- "provider": "cursor",
370
- "input": 0,
371
- "output": 0,
372
- "cache_read": 0,
373
- "cache_write_5m": 0,
374
- "cache_write_1h": 0,
375
- "_note": "Cursor bills by subscription and doesn't record the model locally: no cost is estimated"
376
240
  }
377
241
  },
378
242
  "aliases": {
379
243
  "claude-haiku-4-5": "claude-haiku-4-5-20251001"
380
- },
381
- "_sources": {
382
- "openai": "https://developers.openai.com/api/docs/pricing (standard tier, 2026-09-18)",
383
- "google": "https://ai.google.dev/gemini-api/docs/pricing (paid tier, 2026-09-18)"
384
244
  }
385
245
  }
package/finops/actions.py CHANGED
@@ -440,9 +440,7 @@ def _slug(s):
440
440
  def suggestions(a, f=None):
441
441
  """Skills and MCP servers worth adding, from recurring work (actual transcripts).
442
442
 
443
- Only Claude Code's own prompts and commands count (these are Claude Code skills and
444
- MCP servers; another agent's templated prompts would suggest things Claude never
445
- repeats), within the date range when one is given. Scanning is cached per range on
443
+ Claude Code's own prompts and commands count, within the date range when one is given. Scanning is cached per range on
446
444
  the warehouse (reset by sync); installed-state is re-checked on every call.
447
445
  """
448
446
  f = f or {}
@@ -648,36 +646,32 @@ def free_model_test(log, mid):
648
646
  return {"ok": True, "command": m["command"]}
649
647
 
650
648
 
651
- def compare(a, agents=None, f=None):
652
- """Each selected agent's priced models (with your usage) side by side; free models too
653
- when Claude Code is selected, since they plug into Claude Code."""
649
+ def compare(a, f=None):
650
+ """Claude models (with your usage) side by side with the free models that plug into
651
+ Claude Code."""
654
652
  with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
655
653
  ratings = json.load(fh)["ratings"]
656
- agents = agents or ["claude"]
657
- prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
658
- want = {prov[x] for x in agents if x in prov}
659
- where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
660
- w, wp = a.where(dict(f or {}, agents=agents))
654
+ w, wp = a.where(f or {})
661
655
  used = {r["model"]: r for r in a.q(f"""SELECT r.model, COUNT(*) requests, SUM(r.est_cost_usd) cost,
662
656
  SUM(r.billable_tokens) tokens FROM requests r
663
657
  WHERE {w} GROUP BY r.model""", wp)}
664
658
  rows = []
665
659
  for mid, p in a.pricing.models.items():
666
- pv = p.get("provider", "anthropic")
667
- if p.get("tier") in (None, "none", "other") or pv not in want or mid.endswith("[1m]"):
660
+ if p.get("tier") in (None, "none", "other") or p.get("provider", "anthropic") != "anthropic" \
661
+ or mid.endswith("[1m]"):
668
662
  continue
669
663
  u = used.get(mid) or {}
670
664
  # An unrated Claude model still belongs in the table when you used it; unused
671
665
  # unrated ones (old ids kept for pricing history) would only add noise.
672
- if pv == "anthropic" and mid not in ratings and not u:
666
+ if mid not in ratings and not u:
673
667
  continue
674
668
  rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
675
669
  "speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
676
- rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude" if pv == "anthropic" else pv,
677
- "where": where.get(pv, pv), "price_in": p.get("input"), "price_out": p.get("output"),
670
+ rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude",
671
+ "where": "Anthropic cloud", "price_in": p.get("input"), "price_out": p.get("output"),
678
672
  "context": p.get("context_window"), "requests": u.get("requests") or 0,
679
673
  "cost": u.get("cost") or 0, "tokens": u.get("tokens") or 0, "tier": p.get("tier"), **rt})
680
- fm = free_models() if "claude" in agents else {"models": [], "os": None}
674
+ fm = free_models()
681
675
  for m in fm["models"]:
682
676
  if m["id"] not in ratings:
683
677
  continue
@@ -691,7 +685,6 @@ def compare(a, agents=None, f=None):
691
685
  -rank.get(r.get("tier"), 3))
692
686
  rows.sort(key=lambda r: score(r), reverse=True)
693
687
  return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
694
- "agents": agents,
695
688
  "note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
696
689
  "Models you used that have no rating show — until you add them there. Tool use matters most: coding "
697
690
  "agents work by calling tools, and weak tool use means loops, broken edits "
@@ -64,6 +64,21 @@ def _version():
64
64
  return ""
65
65
 
66
66
 
67
+ def _served_from():
68
+ """Which copy is serving: a git checkout (with its commit) or an npm install."""
69
+ from .paths import ROOT
70
+ out = {"kind": "npm", "path": ROOT, "commit": None}
71
+ if os.path.exists(os.path.join(ROOT, ".git")):
72
+ out["kind"] = "source"
73
+ try:
74
+ import subprocess
75
+ out["commit"] = subprocess.run(["git", "-C", ROOT, "rev-parse", "--short", "HEAD"],
76
+ capture_output=True, text=True, timeout=3).stdout.strip() or None
77
+ except (OSError, subprocess.SubprocessError):
78
+ pass
79
+ return out
80
+
81
+
67
82
  def detect_account():
68
83
  """Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
69
84
 
@@ -257,8 +272,6 @@ class Analytics:
257
272
  cl.append("r.day >= ?"); p.append(f["start"])
258
273
  if f.get("end"):
259
274
  cl.append("r.day <= ?"); p.append(f["end"])
260
- if f.get("agents"):
261
- cl.append("r.agent IN (%s)" % ",".join("?" * len(f["agents"]))); p += f["agents"]
262
275
  if f.get("models"):
263
276
  cl.append("r.model IN (%s)" % ",".join("?" * len(f["models"]))); p += f["models"]
264
277
  if f.get("projects"):
@@ -353,8 +366,8 @@ class Analytics:
353
366
  p).get("n", 0)
354
367
  tot["tool_calls"] = self.one(
355
368
  f"SELECT COALESCE(SUM(r.tool_call_count),0) n FROM requests r WHERE {w}", p).get("n", 0)
356
- # Requests with tokens but no price (an agent without price data, such as Cursor,
357
- # or a model missing from pricing.json): they count, but at $0.
369
+ # Requests with tokens but no price (a model missing from pricing.json):
370
+ # they count, but at $0.
358
371
  tot["unpriced_requests"] = self.one(
359
372
  f"SELECT COUNT(*) n FROM requests r WHERE {w} AND r.est_cost_usd = 0 AND r.billable_tokens > 0",
360
373
  p).get("n", 0)
@@ -683,18 +696,12 @@ class Analytics:
683
696
  r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
684
697
  cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
685
698
  r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
686
- r["budget_tokens"] = self.session_budget(r["agent"], r.pop("project_path"), r["session_id"])
699
+ r["budget_tokens"] = self.session_budget(r.pop("project_path"), r["session_id"])
687
700
  r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
688
701
  return rows
689
702
 
690
- def session_budget(self, agent, project_path, session_id=None):
691
- """The per-session token budget that applies to a session, or None.
692
-
693
- Claude Code sessions only: the guard hook runs there, and other agents'
694
- sessions are not what the budget was set for.
695
- """
696
- if agent != "claude":
697
- return None
703
+ def session_budget(self, project_path, session_id=None):
704
+ """The per-session token budget that applies to a session, or None."""
698
705
  from .guard import budget_for
699
706
  return budget_for(self.settings.get("guard") or {}, project_path, session_id)
700
707
 
@@ -1228,8 +1235,7 @@ class Analytics:
1228
1235
  ORDER BY cost DESC LIMIT 20""", p + [thr])
1229
1236
  agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
1230
1237
  # Utilisation against each request's own model window, weighted by requests —
1231
- # not against the largest window anywhere in the price table (a Gemini window
1232
- # would set the scale for Claude-only usage).
1238
+ # not against the largest window anywhere in the price table.
1233
1239
  num = den = 0.0
1234
1240
  wins = defaultdict(int)
1235
1241
  for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
@@ -1792,8 +1798,8 @@ class Analytics:
1792
1798
  out["lines"].append(self._session_budget_line(f, bp, line))
1793
1799
  else:
1794
1800
  out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
1795
- # Per-project and per-model lines follow the agent selection like every other line.
1796
- wa, pa = self.where({"agents": (f or {}).get("agents") or []})
1801
+ # Per-project and per-model lines cover the whole budget period, not the date filter.
1802
+ wa, pa = self.where({})
1797
1803
  for proj, bud in (b.get("per_project_usd") or {}).items():
1798
1804
  act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1799
1805
  JOIN projects pr ON pr.id=r.project_id
@@ -1850,7 +1856,7 @@ class Analytics:
1850
1856
  p + [bp["start"], bp["end"]])
1851
1857
  scored = []
1852
1858
  for r in rows:
1853
- bud = self.session_budget("claude", r.pop("path"), r["session_id"])
1859
+ bud = self.session_budget(r.pop("path"), r["session_id"])
1854
1860
  if bud:
1855
1861
  r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
1856
1862
  scored.append(r)
@@ -1877,8 +1883,8 @@ class Analytics:
1877
1883
  series = [d for d in self.daily_series(f, end=last_full.isoformat())]
1878
1884
  priced = [d for d in series if d["cost"] > 0]
1879
1885
  if len(priced) >= 14:
1880
- # Median/MAD, not mean/stdev: unpriced $0 days from agents without pricing
1881
- # data (e.g. Cursor) would otherwise pollute the mean/sd baseline and
1886
+ # Median/MAD, not mean/stdev: unpriced $0 days (models without pricing
1887
+ # data) would otherwise pollute the mean/sd baseline and
1882
1888
  # either mask real spikes or manufacture fake ones. MAD is scaled by
1883
1889
  # 1.4826 so it estimates the same thing a standard deviation would under
1884
1890
  # a normal distribution, without a few extreme days inflating it the way
@@ -1905,8 +1911,8 @@ class Analytics:
1905
1911
  # against — which understates how far out they really are. Candidates are
1906
1912
  # ranked by tokens too, since that is the metric being tested; ordering by
1907
1913
  # cost hid token-heavy work on cheap models.
1908
- # Sessions with no token data (Cursor transcripts don't always carry it)
1909
- # are not comparable and would drag the baseline down.
1914
+ # Sessions with no token data are not comparable and would drag the
1915
+ # baseline down.
1910
1916
  vals = [s["tokens"] for s in sess if (s["tokens"] or 0) > 0]
1911
1917
  mean = (statistics.median(vals) if vals else 0) or 1
1912
1918
  # Session sizes are heavy-tailed enough that any fixed multiple of the
@@ -2142,48 +2148,10 @@ class Analytics:
2142
2148
  }
2143
2149
 
2144
2150
  # ---------------- filter option lists ----------------
2145
- def agents(self):
2146
- """Every agent detected on this machine, with what its local data can show."""
2147
- from .agents import AGENTS, detect
2148
- found = detect()
2149
- used = {r["agent"]: r for r in self.q("""SELECT agent, COUNT(*) requests,
2150
- COUNT(DISTINCT session_id) sessions, SUM(billable_tokens) tokens,
2151
- SUM(est_cost_usd) cost, MIN(day) first, MAX(day) last FROM requests GROUP BY agent""")}
2152
- out = []
2153
- for k, a in AGENTS.items():
2154
- u = used.get(k) or {}
2155
- if not found.get(k) and not u:
2156
- continue
2157
- out.append({"id": k, "name": a["name"], "data": a["data"], "note": a["note"],
2158
- "requests": u.get("requests") or 0, "sessions": u.get("sessions") or 0,
2159
- "tokens": u.get("tokens") or 0, "cost": u.get("cost") or 0,
2160
- "first": u.get("first"), "last": u.get("last")})
2161
- return out
2162
-
2163
- def by_agent(self, f=None):
2164
- """Side-by-side totals per agent for the multi-agent view."""
2165
- f = dict(f or {})
2166
- w, p = self.where(f)
2167
- rows = self.q(f"""SELECT r.agent, COUNT(*) requests, COUNT(DISTINCT r.session_id) sessions,
2168
- COUNT(DISTINCT r.prompt_id) prompts, SUM(r.billable_tokens) tokens,
2169
- SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
2170
- SUM(r.cache_read_tokens) cache_read_tokens, SUM(r.est_cost_usd) cost,
2171
- SUM(r.tool_call_count) tool_calls, COUNT(DISTINCT r.day) active_days,
2172
- GROUP_CONCAT(DISTINCT r.model) models
2173
- FROM requests r WHERE {w} GROUP BY r.agent ORDER BY cost DESC, requests DESC""", p)
2174
- daily = self.q(f"""SELECT r.day, r.agent, SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
2175
- COUNT(*) requests FROM requests r WHERE {w} GROUP BY 1, 2 ORDER BY 1""", p)
2176
- from .agents import AGENTS
2177
- for r in rows:
2178
- a = AGENTS.get(r["agent"], {})
2179
- r["name"], r["data"], r["note"] = a.get("name", r["agent"]), a.get("data"), a.get("note")
2180
- return {"agents": rows, "daily": daily, "basis": "estimated"}
2181
-
2182
2151
  def options(self):
2183
2152
  return {
2184
- "agents": self.agents(),
2185
- "models": self.q("SELECT model, agent, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 3 DESC"),
2186
- "projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
2153
+ "models": self.q("SELECT model, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 2 DESC"),
2154
+ "projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, COUNT(r.id) n"
2187
2155
  " FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
2188
2156
  " GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
2189
2157
  "categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
@@ -2199,5 +2167,6 @@ class Analytics:
2199
2167
  "SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
2200
2168
  "settings": self.settings,
2201
2169
  "version": _version(),
2170
+ "served_from": _served_from(),
2202
2171
  "unavailable_label": UNAVAILABLE,
2203
2172
  }
package/finops/api.py CHANGED
@@ -184,7 +184,7 @@ def filters_from(qs):
184
184
  f = {
185
185
  "start": _date("start"),
186
186
  "end": _date("end"),
187
- "agents": lst("agents"), "models": lst("models"), "projects": projects,
187
+ "models": lst("models"), "projects": projects,
188
188
  "sessions": lst("sessions"), "categories": lst("categories"),
189
189
  "include_sandbox": qs.get("include_sandbox", ["1"])[0] != "0",
190
190
  "min_cost": _num("min_cost", float),
@@ -417,7 +417,7 @@ class Handler(BaseHTTPRequestHandler):
417
417
  a = A
418
418
  qs = parse_qs(urlparse(self.path).query)
419
419
  f = filters_from(qs)
420
- return self.send_json(X.compare(a, f["agents"] or None, f))
420
+ return self.send_json(X.compare(a, f))
421
421
  if parts == ["suggestions"]:
422
422
  with _lock:
423
423
  a = A
@@ -433,7 +433,7 @@ class Handler(BaseHTTPRequestHandler):
433
433
  return self.send_json({"error": "unknown route"}, 404)
434
434
 
435
435
  def session_action(self, path):
436
- """POST /api/live/<pid>/<interrupt|close|kill|compact|handover>.
436
+ """POST /api/live/<pid>/<interrupt|close|kill|compact|handover|rename>.
437
437
 
438
438
  Guarded against cross-site requests: a custom header can't be sent by another
439
439
  origin without a CORS preflight (which this server never approves), and any
@@ -448,19 +448,15 @@ class Handler(BaseHTTPRequestHandler):
448
448
  parts = path.strip("/").split("/")
449
449
  if len(parts) != 4 or not parts[2].isdigit():
450
450
  return self.send_json({"ok": False, "error": "bad request"}, 400)
451
- agent = (self._payload or {}).get("agent") or "claude"
452
- if agent != "claude":
453
- from .procs import act_agent
454
- if parts[3] in ("handover", "compact"):
455
- return self.send_json({"ok": False,
456
- "error": f"{parts[3].capitalize()} is only available for Claude Code."})
457
- return self.send_json(act_agent(int(parts[2]), parts[3], agent))
458
451
  if parts[3] == "handover":
459
452
  from .procs import handover
460
453
  return self.send_json(handover(int(parts[2]), self._payload))
461
454
  if parts[3] == "compact":
462
455
  from .procs import compact
463
456
  return self.send_json(compact(int(parts[2]), self._payload))
457
+ if parts[3] == "rename":
458
+ from .procs import rename
459
+ return self.send_json(rename(int(parts[2]), self._payload))
464
460
  return self.send_json(act(int(parts[2]), parts[3]))
465
461
 
466
462
  def do_GET(self):
@@ -473,6 +469,10 @@ class Handler(BaseHTTPRequestHandler):
473
469
  return self.api(path[5:], qs)
474
470
  rel = "index.html" if path in ("/", "") else path.lstrip("/")
475
471
  fp = os.path.normpath(os.path.join(WEB, rel))
472
+ # Every dashboard has its own URL (/live, /sessions …): a path that isn't a
473
+ # file and has no extension is a page of the app, so serve the app.
474
+ if not os.path.isfile(fp) and "." not in os.path.basename(path.rstrip("/")):
475
+ fp = os.path.join(WEB, "index.html")
476
476
  if os.path.commonpath([fp, WEB]) != WEB or not os.path.isfile(fp):
477
477
  return self.send_text("not found", "text/plain", code=404)
478
478
  ext = os.path.splitext(fp)[1]
@@ -516,8 +516,6 @@ class Handler(BaseHTTPRequestHandler):
516
516
  except Exception:
517
517
  o["guard_installed"] = False
518
518
  return self.send_json(o)
519
- if route == "by_agent":
520
- return self.send_json(a.by_agent(f))
521
519
  if route == "overview":
522
520
  return self.send_json(a.overview(f))
523
521
  if route == "burn":
@@ -581,15 +579,13 @@ class Handler(BaseHTTPRequestHandler):
581
579
  from .diagnose import Diagnoser
582
580
  return self.send_json(Diagnoser(a).breakdown(f))
583
581
  if route == "live":
584
- from .procs import list_sessions, list_agent_sessions, ACTIONS
585
- want = [x for x in (g("agents", "") or "").split(",") if x]
586
- claude = [dict(s, agent="claude", signalable=True,
587
- resume=f"claude --resume {s.get('session_id')}")
588
- for s in list_sessions(a.pricing)] if not want or "claude" in want else []
589
- others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
590
- if not want or any(x != "claude" for x in want) else []
591
- rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
582
+ from .procs import list_sessions, ACTIONS
583
+ rows = sorted((dict(s, resume=f"claude --resume {s.get('session_id')}")
584
+ for s in list_sessions(a.pricing)), key=lambda x: -(x.get("context") or 0))
592
585
  return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
586
+ if route.startswith("session_prompts/"):
587
+ from .procs import session_prompts
588
+ return self.send_json(session_prompts(_session_transcript(route[len("session_prompts/"):])))
593
589
  if route.startswith("guard/session/"):
594
590
  return self.send_json(_session_limit_status(a, route[len("guard/session/"):]))
595
591
  if route == "subagents":