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 +36 -41
- package/config/pricing.json +1 -141
- package/finops/actions.py +11 -18
- package/finops/analytics.py +34 -64
- package/finops/api.py +9 -19
- package/finops/cloud.py +10 -107
- package/finops/diagnose.py +12 -85
- package/finops/etl.py +6 -10
- package/finops/procs.py +1 -189
- package/finops/segments.py +2 -2
- package/package.json +2 -5
- package/web/app.js +297 -240
- package/web/styles.css +6 -0
- package/finops/agents.py +0 -271
package/finops/procs.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Running
|
|
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"}
|
package/finops/segments.py
CHANGED
|
@@ -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
|
|
23
|
-
#
|
|
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.
|
|
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,
|