claude-finops 0.16.0 → 0.17.1

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
@@ -10,8 +10,8 @@ It answers, in a few clicks:
10
10
 
11
11
  Everything runs on `127.0.0.1` with the Python standard library. No dependencies, and
12
12
  no data leaves the machine. No outbound calls except a once-a-day version check
13
- against the npm registry (disable with `--no-update-check`), and provider APIs only
14
- if you add a key.
13
+ against the npm registry (disable with `--no-update-check`), and Anthropic's cost API
14
+ only if you add an Admin key.
15
15
 
16
16
  ```bash
17
17
  npx claude-finops
@@ -85,8 +85,8 @@ no reason — each with the estimated money attached.
85
85
 
86
86
  ![Waste detection](https://raw.githubusercontent.com/mohit-raj-purohit/claude-finops/main/docs/img/waste.png)
87
87
 
88
- **Whether the model you are on is the right one.** Per-model cost and efficiency,
89
- plus a switch analysis that prices the same workload on a cheaper model.
88
+ **Whether the model you are on is the right one.** Per-model cost and efficiency, and
89
+ what each model really cost on the same kind of work, measured rather than repriced.
90
90
 
91
91
  ![Model analysis](https://raw.githubusercontent.com/mohit-raj-purohit/claude-finops/main/docs/img/models.png)
92
92
 
@@ -108,7 +108,7 @@ set.
108
108
  or estimated.
109
109
  - **Your data stays put.** It binds to `127.0.0.1`. No outbound calls except a
110
110
  once-a-day version check against the npm registry (disable with
111
- `--no-update-check`), and provider APIs only if you add a key. The warehouse lives
111
+ `--no-update-check`), and Anthropic's cost API only if you add an Admin key. The warehouse lives
112
112
  in `~/.claude-finops`, so upgrading or deleting the package never touches it.
113
113
  - **It tells you what to change**, not just what happened — usually with a prompt you
114
114
  can paste into Claude Code.
@@ -149,7 +149,7 @@ labelled as your own configured figures.
149
149
 
150
150
  **Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
151
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
152
+ interrupt, compact, hand over, rename or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
153
153
  0–100 FinOps scorecard with per-dimension reasoning.
154
154
 
155
155
  **Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
@@ -167,8 +167,9 @@ latency and efficiency; five leaderboards; prompt intelligence (spend by activit
167
167
  and a Claude Code view (tools, files, branches, cost per repository).
168
168
 
169
169
  **Optimize** — A waste detector with seven rules, each showing the exact prompts or
170
- sessions it flagged; a recommendation engine that stays quiet without evidence; and
171
- anomaly detection you can click through to the underlying sessions.
170
+ sessions it flagged; Subagent models, measured per subagent type; a recommendation
171
+ engine that stays quiet without evidence; and anomaly detection you can click through
172
+ to the underlying sessions.
172
173
 
173
174
  **Plan** — Forecast with conservative/expected/high scenario bands, and budgets with
174
175
  Budget → Actual → Forecast → Variance and configurable alert thresholds.
@@ -177,6 +178,11 @@ Global search spans prompt text, sessions, projects, models, tools and dates. Gl
177
178
  filters (date, model, project, category, cost/token thresholds, sandbox toggle) update
178
179
  every chart and KPI. Everything exports to CSV/JSON, plus a printable PDF report.
179
180
 
181
+ Every page, and the side panel when it shows a session or a prompt, has its own URL, so a
182
+ refresh keeps you where you were and Back/Forward work. Next to the version, the header
183
+ says whether the dashboard runs from the npm install or from a source checkout (with its
184
+ commit).
185
+
180
186
  ---
181
187
 
182
188
  ## Configuration
@@ -187,9 +193,10 @@ Two files, both editable without touching code. The server picks up changes to
187
193
  ### `config/pricing.json`
188
194
 
189
195
  Model prices per million tokens, kept strictly separate from usage data so the table
190
- can be updated as prices change. A `claude-*` model with no entry is priced as
191
- `unpriced` ($0, flagged as such in the Model analysis view) rather than silently
192
- billed at another model's rate.
196
+ can be updated as prices change. A new `claude-*` model with no entry yet is priced
197
+ like the newest listed model of its family and marked as such, instead of showing $0.
198
+ A model that isn't Claude (such as a free local model behind a `claude-qwen` launcher)
199
+ costs $0.
193
200
 
194
201
  ### `config/settings.json`
195
202
 
@@ -230,7 +237,7 @@ without it; it only adds the warning and "continue?" prompt inside Claude Code.
230
237
  **Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
231
238
  `~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
232
239
  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.
240
+ variable (`ANTHROPIC_ADMIN_KEY`) takes priority over a stored key.
234
241
 
235
242
  ### Per-session token budget and the session guard
236
243
 
@@ -320,8 +327,8 @@ claude-finops start the dashboard
320
327
  claude-finops --rebuild re-read transcripts, then start
321
328
  claude-finops --stop stop it
322
329
  claude-finops --where where your data, settings and keys live
323
- claude-finops --set-key store a provider API key (hidden prompt, 0600)
324
- claude-finops --keys which provider keys are configured
330
+ claude-finops --set-key store the Anthropic Admin API key (hidden prompt, 0600)
331
+ claude-finops --keys whether that key is configured
325
332
  claude-finops --install-statusline model, context % and 5-hour limit under every prompt
326
333
  claude-finops --uninstall-statusline remove it again
327
334
  claude-finops --install-guard warn near, and ask at, your per-session token budget
@@ -344,7 +351,7 @@ Everything the app writes lives outside the install folder, in one state directo
344
351
  data/cloud_cache.json cached billing figures
345
352
  data/server.pid|.log running server
346
353
  settings.local.json budgets and limits set in the UI
347
- secrets.local.json provider API keys (0600)
354
+ secrets.local.json Anthropic Admin API key (0600)
348
355
  ```
349
356
 
350
357
  Days, "today" and the billing period follow this computer's time zone (Claude Code
@@ -374,8 +381,7 @@ place for you to delete once you are happy.
374
381
  **What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
375
382
  if the Claude desktop app is installed, its Cowork transcripts and
376
383
  `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.
384
+ `%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux).
379
385
 
380
386
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
381
387
  server binds to `127.0.0.1` only, but treat the database and any export you
@@ -383,15 +389,16 @@ generate as sensitive.
383
389
 
384
390
  **Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
385
391
  for when you share your screen. Session titles fall back to their short id. The
386
- setting is remembered in that browser. It only changes what the page shows: the
392
+ setting is remembered in that browser. The side panel has the same button, and using it there
393
+ keeps the panel open. It only changes what the page shows: the
387
394
  database and exports still contain the full text.
388
395
 
389
396
  The app makes exactly one outbound request of its own: once a day it asks
390
397
  `registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
391
398
  you when an upgrade is out (npm has no way to push one at you). It sends nothing
392
399
  about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
393
- `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
394
- configure a key (with `--set-key` or on the Settings page), and only
400
+ `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Anthropic's cost API is called only if you
401
+ add an Admin key (with `--set-key` or on the Settings page), and only
395
402
  when you click Refresh on Billed vs local.
396
403
 
397
404
  ---
@@ -450,13 +457,18 @@ files on Windows.
450
457
  Code saves the name itself. In herdr, the agents sidebar gets the same name. Anywhere it can't
451
458
  type, the command lands on your clipboard.
452
459
  - **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
453
- with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
454
- 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
460
+ with Resume, Compact and Stop (Interrupt), working ones first, then past sessions by their last
461
+ prompt. The sort button at the top puts the last-used past session on top or at the bottom;
462
+ running sessions always stay on top. Click a session to list its prompts, newest first; its own
463
+ sort button flips that list to oldest first. Click a prompt for its full details. Search matches your prompt text.
464
+ It covers all dates but keeps the project filters. Close and Force kill stay on
456
465
  Running sessions.
457
466
  - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
458
467
  (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
459
- interrupt one console process without hitting the others in its window.
468
+ interrupt one console process without hitting the others in its window. A session that is
469
+ working is marked **Working now**, and closing it, or one used in the last 50 minutes, warns you
470
+ first. **Close selected** closes several idle sessions at once; a session that starts working
471
+ before you confirm is skipped.
460
472
  - **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
461
473
  `~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
462
474
  already have.
@@ -508,23 +520,6 @@ The **Jev (fast decisions)** page (sidebar, Optimize):
508
520
  tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
509
521
  published price ($0.042 per million input tokens). Read-only.
510
522
 
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
523
  ---
529
524
 
530
525
  ## 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)
@@ -659,11 +672,12 @@ class Analytics:
659
672
  def sessions(self, f=None, limit=500, order="cost", offset=0):
660
673
  w, p = self.where(f)
661
674
  ob = {"cost": "cost DESC", "tokens": "tokens DESC", "duration": "s.duration_s DESC",
662
- "recent": "s.started_at DESC", "prompts": "prompts DESC"}.get(order, "cost DESC")
675
+ "recent": "s.started_at DESC", "prompts": "prompts DESC",
676
+ "last_active": "last_ts DESC", "last_active_asc": "last_ts ASC"}.get(order, "cost DESC")
663
677
  rows = self.q(f"""
664
678
  SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
665
679
  s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
666
- s.agent, s.source_file, pr.path project_path,
680
+ MAX(r.ts) last_ts, s.agent, s.source_file, pr.path project_path,
667
681
  s.billable_tokens session_tokens,
668
682
  COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
669
683
  SUM(r.tool_call_count) tool_calls,
@@ -683,18 +697,12 @@ class Analytics:
683
697
  r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
684
698
  cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
685
699
  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"])
700
+ r["budget_tokens"] = self.session_budget(r.pop("project_path"), r["session_id"])
687
701
  r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
688
702
  return rows
689
703
 
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
704
+ def session_budget(self, project_path, session_id=None):
705
+ """The per-session token budget that applies to a session, or None."""
698
706
  from .guard import budget_for
699
707
  return budget_for(self.settings.get("guard") or {}, project_path, session_id)
700
708
 
@@ -1228,8 +1236,7 @@ class Analytics:
1228
1236
  ORDER BY cost DESC LIMIT 20""", p + [thr])
1229
1237
  agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
1230
1238
  # 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).
1239
+ # not against the largest window anywhere in the price table.
1233
1240
  num = den = 0.0
1234
1241
  wins = defaultdict(int)
1235
1242
  for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
@@ -1792,8 +1799,8 @@ class Analytics:
1792
1799
  out["lines"].append(self._session_budget_line(f, bp, line))
1793
1800
  else:
1794
1801
  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 []})
1802
+ # Per-project and per-model lines cover the whole budget period, not the date filter.
1803
+ wa, pa = self.where({})
1797
1804
  for proj, bud in (b.get("per_project_usd") or {}).items():
1798
1805
  act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1799
1806
  JOIN projects pr ON pr.id=r.project_id
@@ -1850,7 +1857,7 @@ class Analytics:
1850
1857
  p + [bp["start"], bp["end"]])
1851
1858
  scored = []
1852
1859
  for r in rows:
1853
- bud = self.session_budget("claude", r.pop("path"), r["session_id"])
1860
+ bud = self.session_budget(r.pop("path"), r["session_id"])
1854
1861
  if bud:
1855
1862
  r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
1856
1863
  scored.append(r)
@@ -1877,8 +1884,8 @@ class Analytics:
1877
1884
  series = [d for d in self.daily_series(f, end=last_full.isoformat())]
1878
1885
  priced = [d for d in series if d["cost"] > 0]
1879
1886
  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
1887
+ # Median/MAD, not mean/stdev: unpriced $0 days (models without pricing
1888
+ # data) would otherwise pollute the mean/sd baseline and
1882
1889
  # either mask real spikes or manufacture fake ones. MAD is scaled by
1883
1890
  # 1.4826 so it estimates the same thing a standard deviation would under
1884
1891
  # a normal distribution, without a few extreme days inflating it the way
@@ -1905,8 +1912,8 @@ class Analytics:
1905
1912
  # against — which understates how far out they really are. Candidates are
1906
1913
  # ranked by tokens too, since that is the metric being tested; ordering by
1907
1914
  # 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.
1915
+ # Sessions with no token data are not comparable and would drag the
1916
+ # baseline down.
1910
1917
  vals = [s["tokens"] for s in sess if (s["tokens"] or 0) > 0]
1911
1918
  mean = (statistics.median(vals) if vals else 0) or 1
1912
1919
  # Session sizes are heavy-tailed enough that any fixed multiple of the
@@ -2142,48 +2149,10 @@ class Analytics:
2142
2149
  }
2143
2150
 
2144
2151
  # ---------------- 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
2152
  def options(self):
2183
2153
  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"
2154
+ "models": self.q("SELECT model, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 2 DESC"),
2155
+ "projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, COUNT(r.id) n"
2187
2156
  " FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
2188
2157
  " GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
2189
2158
  "categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
@@ -2199,5 +2168,6 @@ class Analytics:
2199
2168
  "SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
2200
2169
  "settings": self.settings,
2201
2170
  "version": _version(),
2171
+ "served_from": _served_from(),
2202
2172
  "unavailable_label": UNAVAILABLE,
2203
2173
  }