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/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.1",
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,