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/finops/cloud.py CHANGED
@@ -1,4 +1,4 @@
1
- """Billed numbers from the vendors' own APIs, next to what this machine recorded.
1
+ """Billed numbers from Anthropic's Admin API, next to what this machine recorded.
2
2
 
3
3
  Everything else in this dashboard reads local files. This module is the one part that
4
4
  talks to the internet, and only when you ask it to (the Refresh button / --cloud-sync).
@@ -8,18 +8,15 @@ API keys section on the Budgets page. Both write ~/.claude-finops/secrets.local.
8
8
  (0600, outside the install tree, never packaged); the dashboard never sends a stored
9
9
  key back to the browser, only whether it is set and its last four characters:
10
10
 
11
- {"anthropic_admin_key": "sk-ant-admin...", "cursor_api_key": "key_..."}
11
+ {"anthropic_admin_key": "sk-ant-admin..."}
12
12
 
13
13
  Anthropic Admin API key from Console → Settings → Admin keys. Org accounts only;
14
14
  individual accounts have no Admin API.
15
15
  /v1/organizations/usage_report/claude_code per user per day, incl. Pro/Max
16
16
  /v1/organizations/cost_report billed USD (API spend only)
17
- Cursor Team/Org admin key from Cursor dashboard → Settings → Admin API.
18
- POST /teams/daily-usage-data, /teams/spend, GET /teams/members
19
17
 
20
18
  Responses are cached in ~/.claude-finops/data/cloud_cache.json so the dashboard never calls out on its own.
21
19
  """
22
- import base64
23
20
  import json
24
21
  import os
25
22
  import time
@@ -40,12 +37,6 @@ PROVIDERS = {
40
37
  "covers": "Per-user Claude Code sessions, tokens and estimated cost (including Pro/Max "
41
38
  "subscription users), plus billed API cost for the org.",
42
39
  },
43
- "cursor": {
44
- "name": "Cursor", "agent": "cursor", "env": "CURSOR_API_KEY", "field": "cursor_api_key",
45
- "how": "Cursor dashboard → Settings → Admin API → create a key. Team/Business plans only; "
46
- "an individual Pro account has no admin API.",
47
- "covers": "Per-member daily activity and billed spend for the team.",
48
- },
49
40
  }
50
41
 
51
42
 
@@ -70,7 +61,6 @@ def configured():
70
61
  KEY_SHAPES = {
71
62
  "anthropic": (lambda v: v.startswith("sk-ant-admin"),
72
63
  "An Anthropic Admin key starts with sk-ant-admin (a regular API key will not work)."),
73
- "cursor": (lambda v: len(v) >= 16, "That looks too short for a Cursor Admin API key."),
74
64
  }
75
65
 
76
66
 
@@ -198,63 +188,6 @@ def anthropic_cost(key, days=30):
198
188
  return out
199
189
 
200
190
 
201
- # --------------------------------------------------------------------- Cursor ----
202
- def _cursor_headers(key):
203
- tok = base64.b64encode(f"{key}:".encode()).decode() # basic auth, key as username
204
- return {"Authorization": f"Basic {tok}", "Content-Type": "application/json"}
205
-
206
-
207
- def _cursor_post(key, path, body):
208
- try:
209
- return _get(f"https://api.cursor.com{path}", _cursor_headers(key),
210
- data=json.dumps(body).encode(), method="POST")
211
- except RuntimeError as e:
212
- if "401" in str(e): # the key is fine; the account just isn't a team
213
- raise RuntimeError(
214
- "Cursor rejected the key for team endpoints (401 Invalid Team API Key). The key "
215
- "itself is valid, but /teams/* answers only for Team/Enterprise accounts — an "
216
- "individual account has no team data to report. Cursor stays local-only.") from None
217
- raise
218
-
219
-
220
- def cursor_usage(key, days=30):
221
- """Daily per-member activity. The API allows at most 30 days per call."""
222
- end = int(time.time() * 1000)
223
- start = end - min(days, 30) * 86400000
224
- out, page = [], 1
225
- while True:
226
- d = _cursor_post(key, "/teams/daily-usage-data",
227
- {"startDate": start, "endDate": end, "page": page, "pageSize": 1000})
228
- rows = d.get("data") or []
229
- for r in rows:
230
- ts = r.get("date")
231
- day = datetime.fromtimestamp(int(ts) / 1000, timezone.utc).date().isoformat() \
232
- if ts else None
233
- out.append({"day": day, "member": r.get("email") or r.get("userId") or "unknown",
234
- "is_active": r.get("isActive"),
235
- "lines_added": r.get("totalLinesAdded") or 0,
236
- "lines_removed": r.get("totalLinesDeleted") or 0,
237
- "accepted": r.get("acceptedLinesAdded") or 0,
238
- "requests": r.get("composerRequests") or r.get("totalApplies") or 0,
239
- "model": r.get("mostUsedModel")})
240
- if len(rows) < 1000 or page > 20:
241
- break
242
- page += 1
243
- return out
244
-
245
-
246
- def cursor_spend(key):
247
- d = _cursor_post(key, "/teams/spend", {"page": 1, "pageSize": 500})
248
- out = []
249
- for r in d.get("teamMemberSpend") or d.get("data") or []:
250
- cents = r.get("spendCents")
251
- out.append({"member": r.get("email") or r.get("name") or "unknown",
252
- "spend_usd": (float(cents) / 100.0) if cents is not None else None,
253
- "requests": r.get("fastPremiumRequests") or r.get("requests"),
254
- "role": r.get("role")})
255
- return out
256
-
257
-
258
191
  # ---------------------------------------------------------------------- cache ----
259
192
  def load_cache():
260
193
  try:
@@ -281,18 +214,6 @@ def sync(days=30, log=print):
281
214
  cache["anthropic_cost"] = anthropic_cost(k, days)
282
215
  except RuntimeError as e:
283
216
  errors["anthropic_cost"] = str(e)
284
- k = key_for("cursor")
285
- if k:
286
- log("Cursor: daily usage…")
287
- try:
288
- cache["cursor_usage"] = cursor_usage(k, days)
289
- except RuntimeError as e:
290
- errors["cursor_usage"] = str(e)
291
- log("Cursor: spend…")
292
- try:
293
- cache["cursor_spend"] = cursor_spend(k)
294
- except RuntimeError as e:
295
- errors["cursor_spend"] = str(e)
296
217
  cache["errors"] = errors
297
218
  cache["fetched_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
298
219
  os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
@@ -302,20 +223,18 @@ def sync(days=30, log=print):
302
223
 
303
224
 
304
225
  def report(analytics, days=30):
305
- """Billed (vendor API) next to local (this machine), per agent and per day."""
226
+ """Billed (Anthropic API) next to local (this machine), per day and per user."""
306
227
  c = load_cache()
307
228
  cc = c.get("anthropic_claude_code") or []
308
229
  cost = c.get("anthropic_cost") or []
309
- cu = c.get("cursor_usage") or []
310
- cs = c.get("cursor_spend") or []
311
230
  since = (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
312
- # The vendor APIs report UTC days, so this page compares on UTC days too (every
231
+ # The Anthropic API reports UTC days, so this page compares on UTC days too (every
313
232
  # other page uses your local calendar days); otherwise a late-evening session would
314
233
  # sit on different dates on the two sides.
315
- local = {r["agent"]: r for r in analytics.q(
316
- "SELECT agent, SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
234
+ claude_local = analytics.one(
235
+ "SELECT SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
317
236
  " COUNT(DISTINCT session_id) sessions, COUNT(*) requests"
318
- " FROM requests WHERE substr(ts,1,10) >= ? GROUP BY agent", (since,))}
237
+ " FROM requests WHERE agent='claude' AND substr(ts,1,10) >= ?", (since,))
319
238
 
320
239
  by_day = {}
321
240
  for r in cc:
@@ -346,19 +265,6 @@ def report(analytics, days=30):
346
265
  for u in users.values():
347
266
  u["terminals"] = sorted(u["terminals"])
348
267
 
349
- cur = {}
350
- for r in cu:
351
- m = cur.setdefault(r["member"], {"member": r["member"], "days": 0, "lines_added": 0,
352
- "lines_removed": 0, "accepted": 0, "requests": 0})
353
- m["days"] += 1 if r.get("is_active") is not False else 0
354
- for k2 in ("lines_added", "lines_removed", "accepted", "requests"):
355
- m[k2] += r.get(k2) or 0
356
- spend = {s["member"]: s for s in cs}
357
- for m in cur.values():
358
- m["spend_usd"] = (spend.get(m["member"]) or {}).get("spend_usd")
359
-
360
- claude_local = local.get("claude") or {}
361
- cursor_local = local.get("cursor") or {}
362
268
  return {
363
269
  "configured": configured(), "providers": PROVIDERS,
364
270
  "fetched_at": c.get("fetched_at"), "errors": c.get("errors") or {},
@@ -369,20 +275,17 @@ def report(analytics, days=30):
369
275
  "local_claude_cost": claude_local.get("cost") or 0.0,
370
276
  "local_claude_tokens": claude_local.get("tokens") or 0,
371
277
  "billed_api_cost": sum(r["cost_usd"] for r in cost),
372
- "cursor_spend": sum(s["spend_usd"] or 0 for s in cs) if cs else None,
373
- "local_cursor_requests": cursor_local.get("requests") or 0,
374
- "org_users": len(users), "cursor_members": len(cur),
278
+ "org_users": len(users),
375
279
  },
376
280
  "by_day": sorted(by_day.values(), key=lambda x: x["day"]),
377
281
  "users": sorted(users.values(), key=lambda x: -x["cost"]),
378
- "cursor_members": sorted(cur.values(), key=lambda x: -(x["spend_usd"] or 0)),
379
282
  "api_cost_by_description": sorted(
380
283
  [{"description": k2, "cost_usd": v} for k2, v in
381
284
  {r["description"]: sum(x["cost_usd"] for x in cost if x["description"] == r["description"])
382
285
  for r in cost}.items()], key=lambda x: -x["cost_usd"])[:20],
383
- "note": "Billed figures come from the vendor APIs (org-wide, every machine and member). "
286
+ "note": "Billed figures come from the Anthropic Admin API (org-wide, every machine and member). "
384
287
  "Local figures are what this machine's transcripts recorded. A gap usually means "
385
288
  "other machines, other members, or work outside this machine. Days on this "
386
- "page are UTC days, as the vendors report them.",
289
+ "page are UTC days, as Anthropic reports them.",
387
290
  "basis": "billed",
388
291
  }
@@ -19,7 +19,7 @@ CHARS_PER_TOKEN = 4 # rough, stated wherever it is used
19
19
  CLAUDE_MD_WARN_TOKENS = 2500
20
20
  MEMORY_WARN_TOKENS = 1500
21
21
  EXPLORE_BASH = re.compile(r"^\s*(cd [^;&]+[;&]+\s*)?(ls|find|grep|rg|cat|head|tail|sed -n|tree|wc)\b")
22
- # What each agent calls the same things, so advice names the right file and command.
22
+ # The names advice uses for Claude Code's files and commands.
23
23
  VOCAB = {
24
24
  "claude": {"name": "Claude Code", "md": "CLAUDE.md", "md_paths": ("CLAUDE.md", ".claude/CLAUDE.md"),
25
25
  "clear": "/clear", "compact": "/compact", "shell": ("Bash",),
@@ -28,18 +28,6 @@ VOCAB = {
28
28
  "\"model\" in ~/.claude/settings.json). Give subagents a cheaper model in "
29
29
  "their agent definition.",
30
30
  "cheaper": "Default to Sonnet; switch to Opus only for hard problems"},
31
- "codex": {"name": "Codex", "md": "AGENTS.md", "md_paths": ("AGENTS.md",),
32
- "clear": "/new", "compact": "/compact", "shell": ("exec_command", "shell", "local_shell"),
33
- "explore": (),
34
- "model_how": "Use /model to pick a smaller GPT-5 model (or lower reasoning effort) for "
35
- "routine edits, tests and docs; set model = \"...\" in ~/.codex/config.toml.",
36
- "cheaper": "Use a cheaper GPT model for routine work"},
37
- "gemini": {"name": "Gemini CLI", "md": "GEMINI.md", "md_paths": ("GEMINI.md", ".gemini/GEMINI.md"),
38
- "clear": "/clear", "compact": "/compress", "shell": ("run_shell_command",),
39
- "explore": ("read_file", "read_many_files", "glob", "search_file_content", "list_directory"),
40
- "model_how": "Run gemini -m gemini-2.5-flash (or /model) for routine edits, tests "
41
- "and docs; set \"model\" in ~/.gemini/settings.json.",
42
- "cheaper": "Default to Flash; switch to Pro only for hard problems"},
43
31
  }
44
32
  NOISY_PATH = re.compile(r"(node_modules/|/dist/|/build/|/\.next/|/coverage/|package-lock\.json|"
45
33
  r"yarn\.lock|pnpm-lock\.yaml|\.min\.js|\.map$|/data/.*\.(json|csv)$)")
@@ -300,40 +288,6 @@ class Diagnoser:
300
288
  "custom_agents": sorted(os.listdir(agents)) if os.path.isdir(agents) else [],
301
289
  "default_model": st.get("model"), "issues": issues}
302
290
 
303
- def agent_global_config(self):
304
- """Codex / Gemini equivalent of global_config(): instructions file, default model, MCP."""
305
- v, issues, files, model, mcp = self.v, [], [], None, []
306
- base = os.path.join(HOME, ".codex" if self.agent == "codex" else ".gemini")
307
- for rel in (v["md"], "config.toml", "settings.json"):
308
- p = os.path.join(base, rel)
309
- if os.path.exists(p):
310
- files.append({"path": p, "tokens": _tok(p)})
311
- if self.agent == "codex":
312
- try:
313
- txt = open(os.path.join(base, "config.toml"), errors="replace").read()
314
- except OSError:
315
- txt = ""
316
- m = re.search(r'^\s*model\s*=\s*"([^"]+)"', txt, re.M)
317
- model = m.group(1) if m else None
318
- mcp = re.findall(r'^\s*\[mcp_servers\.([^\]]+)\]', txt, re.M)
319
- where = "model = \"...\" in ~/.codex/config.toml"
320
- else:
321
- st = _load_json(os.path.join(base, "settings.json"))
322
- model = (st.get("model") or {}).get("name") if isinstance(st.get("model"), dict) else st.get("model")
323
- mcp = list((st.get("mcpServers") or {}).keys())
324
- where = "\"model\": {\"name\": \"...\"} in ~/.gemini/settings.json"
325
- g = next((x for x in files if x["path"].endswith(v["md"])), None)
326
- if g and g["tokens"] and g["tokens"] > CLAUDE_MD_WARN_TOKENS:
327
- issues.append({"severity": "medium", "title": f"Global {v['md']} is large",
328
- "fix": f"~{g['tokens']:,} tokens load into every session in every "
329
- f"project. Move project-specific parts into that project."})
330
- if not model:
331
- issues.append({"severity": "low", "title": "No default model set",
332
- "fix": f"Set a cheaper everyday default with {where}; switch up with "
333
- f"/model when a task needs it."})
334
- return {"files": files, "mcp_servers": mcp, "custom_agents": [],
335
- "default_model": model, "issues": issues}
336
-
337
291
  def projects(self, f):
338
292
  a = self.a
339
293
  w, p = a.where(f)
@@ -352,7 +306,6 @@ class Diagnoser:
352
306
  ph = ",".join("?" * len(pids))
353
307
  exists = bool(path) and os.path.isdir(path)
354
308
  v = getattr(self, "v", VOCAB["claude"])
355
- claude = v is VOCAB["claude"]
356
309
  cm = [os.path.join(path, x) for x in v["md_paths"]] if path else []
357
310
  cm_found = [x for x in cm if os.path.exists(x)]
358
311
  cm_tok = sum(_tok(x) or 0 for x in cm_found)
@@ -415,18 +368,15 @@ class Diagnoser:
415
368
  "\"Read(./dist/**)\", \"Read(./**/*.lock)\"]}. Seen: "
416
369
  + ", ".join(os.path.basename(x["path"]) for x in noisy[:3]),
417
370
  "evidence": noisy})
418
- unused = [m for m in mcp_per.get(path, []) if m not in mcp_used] if claude else []
371
+ unused = [m for m in mcp_per.get(path, []) if m not in mcp_used]
419
372
  if unused:
420
373
  issues.append({"severity": "low", "title": "Unused MCP servers enabled",
421
374
  "fix": f"{', '.join(unused)} never called here but their tool "
422
375
  f"definitions load every session. Remove with "
423
376
  f"`claude mcp remove <name>`."})
424
- if claude and exists and cm_found:
377
+ if exists and cm_found:
425
378
  issues += self.claude_md_quality(path, cm_found)
426
- if claude:
427
- issues += self.memory_quality(pj["slug"], heavy)
428
- else: # Claude-only files: don't flag them
429
- issues = [i for i in issues if not i["title"].startswith(("MEMORY.md", "Generated/large"))]
379
+ issues += self.memory_quality(pj["slug"], heavy)
430
380
  if not exists:
431
381
  issues = []
432
382
  out.append({
@@ -440,8 +390,7 @@ class Diagnoser:
440
390
  "deny_rules": len(((s_json or {}).get("permissions") or {}).get("deny") or [])},
441
391
  "mcp_servers": mcp_per.get(path, []),
442
392
  "issues": issues,
443
- "harness": self.harness(pj, path, exists, cm_found, s_json, explore_pct, pids, ph)
444
- if claude else None,
393
+ "harness": self.harness(pj, path, exists, cm_found, s_json, explore_pct, pids, ph),
445
394
  })
446
395
  return out
447
396
 
@@ -629,8 +578,7 @@ class Diagnoser:
629
578
  "skills": skills, "slash_commands": slash,
630
579
  "mcp": [r for r in mcp if r["kind"] == "mcp"],
631
580
  "connectors": [r for r in mcp if r["kind"] == "connector"],
632
- "configured_unused_mcp": sorted(configured - used)
633
- if "claude" in ((f or {}).get("agents") or ["claude"]) else [],
581
+ "configured_unused_mcp": sorted(configured - used),
634
582
  "cache_read_rate_per_mtok": rate * 1e6,
635
583
  "note": "Injected = size of what came back into context (actual size, ~4 chars/token). "
636
584
  "Carried = injected × later requests in the same session that re-read it — an "
@@ -971,46 +919,25 @@ class Diagnoser:
971
919
  return out[:40]
972
920
 
973
921
  # ------------------------------------------------------------------ entry point
974
- def _agent(self, f):
975
- """The agent the advice is written for: the one that spent most tokens in range."""
976
- w, p = self.a.where(f)
977
- r = self.a.one(f"SELECT r.agent a FROM requests r WHERE {w} AND r.agent IN "
978
- f"('claude','codex','gemini') GROUP BY r.agent "
979
- f"ORDER BY SUM(r.billable_tokens) DESC LIMIT 1", p)
980
- return r.get("a") or "claude"
981
-
982
922
  def run(self, f=None):
983
923
  f = f or {}
984
- self.agent = self._agent(f)
985
- self.v = VOCAB[self.agent]
924
+ self.v = VOCAB["claude"]
986
925
  drv = self.drivers(f)
987
926
  tr = self.trend(f)
988
927
  pj = self.projects(f)
989
- gl = self.global_config() if self.agent == "claude" else self.agent_global_config()
928
+ gl = self.global_config()
990
929
  recs = self.recommendations(f, drv, pj)
991
930
  top = drv["drivers"][:3]
992
931
  headline = ("Your tokens are high mainly because of: "
993
932
  + "; ".join(f"{d['title'].lower()} ({d['share_pct']}%)" for d in top)
994
933
  + ".") if top else "No usage in the selected range."
995
934
  from .playbook import attach
996
- wants = f.get("agents") or ["claude"]
997
935
  res = attach({"headline": headline, "trend": tr, **drv, "recommendations": recs,
998
- "live_sessions": self.live_sessions() if "claude" in wants else [], "session_health": self.session_health(f),
936
+ "live_sessions": self.live_sessions(), "session_health": self.session_health(f),
999
937
  "memory_suggestions": self.memory_suggestions(f),
1000
938
  "projects": pj, "global": gl,
1001
939
  "note": "Token counts are actual. Dollar figures are estimated. Config checks "
1002
940
  f"read files on disk now; token sizes use ~{CHARS_PER_TOKEN} chars/token.",
1003
- "basis": "mixed", "agent": self.agent, "vocab": {k: v for k, v in self.v.items()
1004
- if isinstance(v, str)}})
1005
- return res if self.agent == "claude" else _swap_md(res, self.v["md"])
1006
-
1007
-
1008
- def _swap_md(obj, md):
1009
- """Advice text for a non-Claude agent: point at its instructions file, not CLAUDE.md."""
1010
- if isinstance(obj, str):
1011
- return obj.replace("CLAUDE.md", md).replace("Claude Code", "the agent")
1012
- if isinstance(obj, list):
1013
- return [_swap_md(x, md) for x in obj]
1014
- if isinstance(obj, dict):
1015
- return {k: (x if k in ("path", "paths", "session_id") else _swap_md(x, md)) for k, x in obj.items()}
1016
- return obj
941
+ "basis": "mixed", "vocab": {k: v for k, v in self.v.items() if isinstance(v, str)}})
942
+ return res
943
+
package/finops/etl.py CHANGED
@@ -110,7 +110,7 @@ CREATE TABLE requests (
110
110
  tool_call_count INTEGER DEFAULT 0,
111
111
  is_sidechain INTEGER DEFAULT 0,
112
112
  agent_id TEXT, agent_type TEXT, agent_desc TEXT, -- set for subagent requests
113
- agent TEXT DEFAULT 'claude' -- claude / codex / gemini / cursor
113
+ agent TEXT DEFAULT 'claude'
114
114
  );
115
115
 
116
116
  CREATE TABLE tool_calls (
@@ -218,9 +218,8 @@ def keep_deleted_history():
218
218
 
219
219
 
220
220
  class Loader:
221
- def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
221
+ def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None,
222
222
  desktop_roots=None, keep_from=None):
223
- self.other_agents = other_agents
224
223
  # An explicit source means "load exactly this"; only the default also picks up
225
224
  # the desktop app's Cowork sessions (same JSONL format, different config dir).
226
225
  self.desktop_roots = (desktop_roots if desktop_roots is not None
@@ -294,12 +293,6 @@ class Loader:
294
293
  except Exception as exc: # a corrupt transcript must not kill the load
295
294
  print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
296
295
  self.cowork = False
297
- if self.other_agents:
298
- from .agents import AgentLoader
299
- counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
300
- if verbose and counts:
301
- print(" other agents: " + ", ".join(f"{k} {v:,} requests" for k, v in counts.items()),
302
- file=sys.stderr)
303
296
  carried = 0
304
297
  if prev and keep_deleted_history() and os.path.exists(prev):
305
298
  self.db.commit() # the fresh read is safe whatever happens next
@@ -647,8 +640,11 @@ class Loader:
647
640
  d.row_factory = sqlite3.Row
648
641
  cols = lambda db, t: [r[1] for r in d.execute(f"PRAGMA {db}.table_info({t})")]
649
642
  common = lambda t: [c for c in cols("main", t) if c in set(cols("old", t)) and c != "id"]
643
+ # Only Claude Code sessions: warehouses from before 0.17 also held Codex,
644
+ # Gemini CLI and Cursor rows, which are no longer read.
645
+ claude_only = " AND COALESCE(agent, 'claude') = 'claude'" if "agent" in cols("old", "sessions") else ""
650
646
  gone = [r for r in d.execute(
651
- "SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)")
647
+ "SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)" + claude_only)
652
648
  if r["source_file"] and not os.path.exists(r["source_file"])]
653
649
  if not gone:
654
650
  return 0
package/finops/procs.py CHANGED
@@ -1,4 +1,4 @@
1
- """Running agent sessions (Claude Code, Codex, Gemini CLI, Cursor) and the actions the dashboard can take on them.
1
+ """Running Claude Code sessions and the actions the dashboard can take on them.
2
2
 
3
3
  Source of truth is ~/.claude/sessions/<pid>.json (written by Claude Code), checked
4
4
  against the live process table. A signal is only ever sent to a PID that, at the
@@ -191,194 +191,6 @@ def act(pid, action):
191
191
  return {"ok": True, "message": msg}
192
192
 
193
193
 
194
- # ------------------------------------------------------------ other agents ----
195
- # Codex, Gemini CLI and Cursor keep no pid registry, so a session counts as running
196
- # when its transcript was written recently. A CLI session is tied to a process by
197
- # working directory; Cursor sessions live inside the IDE and are never signalled.
198
- LIVE_WINDOW_S = 20 * 60
199
- BUSY_WINDOW_S = 60
200
- AGENT_BINS = {"codex": ("codex",), "gemini": ("gemini",)}
201
- RESUME = {"codex": "codex resume {sid}", "gemini": "gemini --resume"}
202
-
203
-
204
- def _is_agent(p, agent):
205
- if agent == "claude":
206
- return _is_claude(p)
207
- words = [os.path.basename(w.strip('"').replace("\\", "/")).lower()
208
- for w in (p["command"] or "").split()[:3]]
209
- return any(w in AGENT_BINS.get(agent, ()) or w in tuple(b + ".exe" for b in AGENT_BINS.get(agent, ()))
210
- for w in words)
211
-
212
-
213
- def _cwd_of(pid):
214
- if os.name == "nt":
215
- return None
216
- try:
217
- out = subprocess.run(["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
218
- capture_output=True, text=True, timeout=5).stdout
219
- except (OSError, subprocess.TimeoutExpired):
220
- return None
221
- return next((l[1:] for l in out.splitlines() if l.startswith("n")), None)
222
-
223
-
224
- def _recent(pattern, now):
225
- out = []
226
- for fp in glob.glob(pattern, recursive=True):
227
- try:
228
- age = now - os.path.getmtime(fp)
229
- except OSError:
230
- continue
231
- if age <= LIVE_WINDOW_S:
232
- out.append((fp, age))
233
- return out
234
-
235
-
236
- def _codex_live(fp, pricing):
237
- meta, model, steps, tokens, ctx, cost, prev = {}, "unknown", 0, 0, None, 0.0, None
238
- with open(fp, errors="replace") as fh:
239
- for line in fh:
240
- try:
241
- r = json.loads(line)
242
- except ValueError:
243
- continue
244
- typ, p = r.get("type"), r.get("payload") or {}
245
- if typ == "session_meta":
246
- meta = p
247
- elif typ == "turn_context":
248
- model = p.get("model") or model
249
- elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
250
- tot = tuple(sorted(((p["info"] or {}).get("total_token_usage") or {}).items()))
251
- if tot == prev:
252
- continue
253
- prev = tot
254
- last = (p["info"] or {}).get("last_token_usage") or {}
255
- cached = int(last.get("cached_input_tokens") or 0)
256
- inp, out = int(last.get("input_tokens") or 0), int(last.get("output_tokens") or 0)
257
- steps += 1
258
- tokens += inp + out
259
- ctx = inp
260
- if pricing.is_known(model):
261
- cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
262
- return {"session_id": meta.get("id"), "cwd": meta.get("cwd"), "version": meta.get("cli_version"),
263
- "model": model, "steps": steps, "tokens": tokens, "context": ctx, "est_cost_usd": cost}
264
-
265
-
266
- def _gemini_live(fp, pricing):
267
- with open(fp, errors="replace") as fh:
268
- d = json.load(fh)
269
- steps, tokens, ctx, cost, model = 0, 0, None, 0.0, None
270
- for m in d.get("messages") or []:
271
- if m.get("type") != "gemini":
272
- continue
273
- t = m.get("tokens") or {}
274
- model = m.get("model") or model
275
- cached, inp = int(t.get("cached") or 0), int(t.get("input") or 0)
276
- out = int(t.get("output") or 0) + int(t.get("thoughts") or 0)
277
- steps += 1
278
- tokens += inp + out
279
- ctx = inp
280
- if pricing.is_known(model):
281
- cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
282
- return {"session_id": d.get("sessionId"), "cwd": None, "model": model, "steps": steps,
283
- "tokens": tokens, "context": ctx, "est_cost_usd": cost}
284
-
285
-
286
- def _cursor_live(fp, pricing):
287
- steps = 0
288
- with open(fp, errors="replace") as fh:
289
- for line in fh:
290
- if '"tool_use"' in line:
291
- steps += line.count('"tool_use"')
292
- slug = os.path.relpath(fp, os.path.join(HOME, ".cursor", "projects")).split(os.sep)[0]
293
- guess = "/" + slug.replace("-", "/")
294
- return {"session_id": os.path.splitext(os.path.basename(fp))[0],
295
- "cwd": guess if os.path.isdir(guess) else None, "project_hint": slug.rsplit("-", 1)[-1],
296
- "model": None, "steps": steps, "tokens": None, "context": None, "est_cost_usd": None}
297
-
298
-
299
- LIVE_SOURCES = {
300
- "codex": (os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"), _codex_live),
301
- "gemini": (os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"), _gemini_live),
302
- "cursor": (os.path.join(HOME, ".cursor", "projects", "*", "agent-transcripts", "*", "*.jsonl"), _cursor_live),
303
- }
304
-
305
-
306
- def list_agent_sessions(pricing, agents=None, procs=None):
307
- procs = procs if procs is not None else _ps()
308
- now = time.time()
309
- cli = {a: [(pid, p) for pid, p in procs.items() if _is_agent(p, a)] for a in AGENT_BINS}
310
- cwds = {}
311
- out = []
312
- for agent, (pattern, parse) in LIVE_SOURCES.items():
313
- if agents and agent not in agents:
314
- continue
315
- for fp, age in _recent(pattern, now):
316
- try:
317
- st = parse(fp, pricing)
318
- except (OSError, ValueError):
319
- continue
320
- match = None
321
- for pid, p in cli.get(agent, []):
322
- if pid not in cwds:
323
- cwds[pid] = _cwd_of(pid)
324
- if st.get("cwd") is None or cwds[pid] == st.get("cwd"):
325
- match = (pid, p)
326
- break
327
- ctx = st.get("context") or 0
328
- cwd = st.get("cwd")
329
- out.append({
330
- "agent": agent, "pid": match[0] if match else None,
331
- "signalable": bool(match), "session_id": st.get("session_id"), "name": None,
332
- "cwd": cwd, "project": os.path.basename(cwd or "") or st.get("project_hint") or agent,
333
- "status": "busy" if age <= BUSY_WINDOW_S else "idle", "version": st.get("version"),
334
- "model": st.get("model"), "uptime": match[1]["etime"] if match else "",
335
- "memory_mb": round(match[1]["rss_kb"] / 1024) if match else None,
336
- "hosts_dashboard": False, "transcript": fp, "last_write_s": age,
337
- "steps": st.get("steps"), "tokens": st.get("tokens"), "context": st.get("context"),
338
- "est_cost_usd": st.get("est_cost_usd"),
339
- "resume": RESUME.get(agent, "").format(sid=st.get("session_id") or "") or None,
340
- "severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
341
- })
342
- return out
343
-
344
-
345
- def act_agent(pid, action, agent):
346
- """Signal a Codex / Gemini CLI process — only one the live list currently shows."""
347
- if action not in ACTIONS:
348
- return {"ok": False, "error": f"unknown action {action}"}
349
- if agent not in AGENT_BINS:
350
- return {"ok": False, "error": "This agent's sessions can't be stopped from here."}
351
- procs = _ps()
352
- p = procs.get(pid)
353
- if not p or not _is_agent(p, agent):
354
- return {"ok": False, "error": "Process is no longer running."}
355
- live = [s for s in list_agent_sessions(_NoPricing(), [agent], procs) if s["pid"] == pid]
356
- if not live:
357
- return {"ok": False, "error": "Not a live session the dashboard shows; refusing."}
358
- sig, msg = ACTIONS[action]
359
- try:
360
- os.kill(pid, sig)
361
- except ProcessLookupError:
362
- return {"ok": False, "error": "Process already exited."}
363
- except PermissionError:
364
- return {"ok": False, "error": "Not permitted to signal this process."}
365
- res = {"ok": True, "message": msg.replace("`claude --resume`", "its resume command"),
366
- "resume": live[0]["resume"]}
367
- if action in ("close", "kill"):
368
- for _ in range(20):
369
- time.sleep(0.1)
370
- if pid not in _ps():
371
- res["exited"] = True
372
- return res
373
- res["exited"] = False
374
- return res
375
-
376
-
377
- class _NoPricing:
378
- def is_known(self, model):
379
- return False
380
-
381
-
382
194
  # ---------------------------------------------------------------- handover ----
383
195
  HANDOFFS = os.path.join(HOME, ".claude", "finops-handoffs")
384
196
  EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
@@ -19,8 +19,8 @@ Boundaries this can see:
19
19
  from datetime import datetime
20
20
 
21
21
  # Multipliers are derived per model from the price table rather than hardcoded: the
22
- # 0.1x read / 1.25x 5m / 2.0x 1h shape holds for Anthropic, but Gemini reads at 0.25x
23
- # and OpenAI writes at 1.0x, so a fixed constant would quietly corrupt those agents.
22
+ # 0.1x read / 1.25x 5m / 2.0x 1h shape holds for today's Claude models, but a fixed
23
+ # constant would quietly go wrong the day a model is priced differently.
24
24
  READ = "cache_read"
25
25
  W5M = "cache_write_5m"
26
26
  W1H = "cache_write_1h"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.16.0",
3
+ "version": "0.17.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"
@@ -33,10 +33,7 @@
33
33
  "dashboard",
34
34
  "finops",
35
35
  "analytics",
36
- "cli",
37
- "codex",
38
- "gemini-cli",
39
- "cursor"
36
+ "cli"
40
37
  ],
41
38
  "license": "MIT",
42
39
  "private": false,