claude-finops 0.16.0 → 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
@@ -452,7 +451,7 @@ files on Windows.
452
451
  - **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
453
452
  with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
454
453
  list its prompts, and click a prompt for its full details. Search matches your prompt text.
455
- 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
456
455
  Running sessions.
457
456
  - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
458
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
@@ -508,23 +507,6 @@ The **Jev (fast decisions)** page (sidebar, Optimize):
508
507
  tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
509
508
  published price ($0.042 per million input tokens). Read-only.
510
509
 
511
- ---
512
-
513
- ## Multi-agent
514
-
515
- Besides Claude Code, the warehouse loads every other coding agent it finds on the machine:
516
-
517
- | Agent | Read from | What it gives |
518
- |---|---|---|
519
- | Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
520
- | Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
521
- | 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 |
522
-
523
- Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
524
- everything. Every page follows the selection, and **Agents** shows them side by side.
525
- Prices for the other providers live in `config/pricing.json` with their source URLs.
526
-
527
-
528
510
  ---
529
511
 
530
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
@@ -448,13 +448,6 @@ 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", "rename"):
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))
@@ -476,6 +469,10 @@ class Handler(BaseHTTPRequestHandler):
476
469
  return self.api(path[5:], qs)
477
470
  rel = "index.html" if path in ("/", "") else path.lstrip("/")
478
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")
479
476
  if os.path.commonpath([fp, WEB]) != WEB or not os.path.isfile(fp):
480
477
  return self.send_text("not found", "text/plain", code=404)
481
478
  ext = os.path.splitext(fp)[1]
@@ -519,8 +516,6 @@ class Handler(BaseHTTPRequestHandler):
519
516
  except Exception:
520
517
  o["guard_installed"] = False
521
518
  return self.send_json(o)
522
- if route == "by_agent":
523
- return self.send_json(a.by_agent(f))
524
519
  if route == "overview":
525
520
  return self.send_json(a.overview(f))
526
521
  if route == "burn":
@@ -584,14 +579,9 @@ class Handler(BaseHTTPRequestHandler):
584
579
  from .diagnose import Diagnoser
585
580
  return self.send_json(Diagnoser(a).breakdown(f))
586
581
  if route == "live":
587
- from .procs import list_sessions, list_agent_sessions, ACTIONS
588
- want = [x for x in (g("agents", "") or "").split(",") if x]
589
- claude = [dict(s, agent="claude", signalable=True,
590
- resume=f"claude --resume {s.get('session_id')}")
591
- for s in list_sessions(a.pricing)] if not want or "claude" in want else []
592
- others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
593
- if not want or any(x != "claude" for x in want) else []
594
- 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))
595
585
  return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
596
586
  if route.startswith("session_prompts/"):
597
587
  from .procs import session_prompts