claude-finops 0.13.0 → 0.15.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/etl.py CHANGED
@@ -14,9 +14,10 @@ import sys
14
14
  from datetime import datetime, timezone
15
15
 
16
16
  from .classify import classify
17
+ from . import localtime
17
18
  from .pricing import Pricing
18
19
 
19
- from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
20
+ from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS, SETTINGS_PATH, LOCAL_SETTINGS_PATH
20
21
  DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
21
22
 
22
23
  SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
@@ -202,9 +203,23 @@ def _slug_to_name(slug):
202
203
  return os.path.basename(p.rstrip("/")) or slug
203
204
 
204
205
 
206
+ def keep_deleted_history():
207
+ """history.keep_deleted_transcripts (default on), local settings winning over shared."""
208
+ val = True
209
+ for path in (SETTINGS_PATH, LOCAL_SETTINGS_PATH):
210
+ try:
211
+ with open(path) as fh:
212
+ h = (json.load(fh) or {}).get("history") or {}
213
+ if isinstance(h.get("keep_deleted_transcripts"), bool):
214
+ val = h["keep_deleted_transcripts"]
215
+ except (OSError, ValueError, AttributeError):
216
+ pass
217
+ return val
218
+
219
+
205
220
  class Loader:
206
221
  def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
207
- desktop_roots=None):
222
+ desktop_roots=None, keep_from=None):
208
223
  self.other_agents = other_agents
209
224
  # An explicit source means "load exactly this"; only the default also picks up
210
225
  # the desktop app's Cowork sessions (same JSONL format, different config dir).
@@ -220,11 +235,30 @@ class Loader:
220
235
  self.pending_results = {}
221
236
  self.group = None
222
237
  self.seen_request_ids = set() # dedup request_id across sessions (resumed sessions)
238
+ # The warehouse this build replaces: sessions whose transcript has since been
239
+ # deleted are carried over from it, so history outlives Claude Code's cleanup.
240
+ self.keep_from = keep_from
223
241
 
224
242
  # ---------- infrastructure ----------
225
243
  def build(self, verbose=True):
244
+ prev = self.keep_from
245
+ moved = None
226
246
  if os.path.exists(self.db_path):
227
- os.remove(self.db_path)
247
+ if prev is None and keep_deleted_history():
248
+ moved = prev = self.db_path + ".prev"
249
+ # fold the write-ahead log into the file first: moving the file without
250
+ # its -wal leaves a database SQLite can no longer read
251
+ try:
252
+ c = sqlite3.connect(self.db_path)
253
+ c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
254
+ c.close()
255
+ except sqlite3.Error:
256
+ pass
257
+ for suffix in ("", "-wal", "-shm"):
258
+ if os.path.exists(self.db_path + suffix):
259
+ os.replace(self.db_path + suffix, moved + suffix)
260
+ else:
261
+ os.remove(self.db_path)
228
262
  for suffix in ("-wal", "-shm"):
229
263
  p = self.db_path + suffix
230
264
  if os.path.exists(p):
@@ -237,7 +271,16 @@ class Loader:
237
271
  for n in names:
238
272
  if n.endswith(".jsonl"):
239
273
  files.append(os.path.join(dirpath, n))
240
- files.sort()
274
+
275
+ def created(fp):
276
+ # A resumed session copies earlier responses into a new, later file; reading
277
+ # files oldest-created first credits each response to the session that made it.
278
+ try:
279
+ st = os.stat(fp)
280
+ return (getattr(st, "st_birthtime", None) or st.st_mtime, fp)
281
+ except OSError:
282
+ return (float("inf"), fp)
283
+ files.sort(key=created)
241
284
  marker = os.sep + os.path.join(".claude", "projects") + os.sep
242
285
  desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
243
286
  for d, _, names in os.walk(root) for n in names
@@ -257,16 +300,34 @@ class Loader:
257
300
  if verbose and counts:
258
301
  print(" other agents: " + ", ".join(f"{k} {v:,} requests" for k, v in counts.items()),
259
302
  file=sys.stderr)
303
+ carried = 0
304
+ if prev and keep_deleted_history() and os.path.exists(prev):
305
+ self.db.commit() # the fresh read is safe whatever happens next
306
+ try:
307
+ carried = self.carry_over(prev)
308
+ except Exception as exc: # an old or damaged warehouse must not stop a build
309
+ print(f" ! kept no deleted history: {exc}", file=sys.stderr)
310
+ self.db.rollback()
311
+ if verbose and carried:
312
+ print(f" kept {carried} sessions whose transcripts were deleted", file=sys.stderr)
313
+ if moved:
314
+ for suffix in ("", "-wal", "-shm"):
315
+ if os.path.exists(moved + suffix):
316
+ os.remove(moved + suffix)
260
317
  self.rollup()
261
318
  self.db.execute(
262
319
  "INSERT INTO meta VALUES (?,?)",
263
320
  ("built_at", datetime.now(timezone.utc).isoformat()),
264
321
  )
322
+ self.db.execute("INSERT INTO meta VALUES (?,?)", ("kept_deleted_sessions", str(carried)))
265
323
  for k, v in (
266
324
  ("source_dir", self.source),
267
325
  ("transcript_files", str(len(files))),
268
326
  ("desktop_transcript_files", str(len(desktop))),
269
327
  ("pricing_updated", str(self.pricing.updated)),
328
+ # day/hour columns are local calendar days (finops/localtime.py); older
329
+ # warehouses cut them at UTC and the readers fall back accordingly
330
+ ("day_basis", "local"),
270
331
  ("pricing_source", str(self.pricing.source)),
271
332
  ("cost_basis", "estimated"),
272
333
  ("schema_version", str(SCHEMA_VERSION)),
@@ -452,7 +513,7 @@ class Loader:
452
513
  "INSERT INTO prompts (uuid, session_id, project_id, ts, day, text, char_len,"
453
514
  " word_len, category, category_confidence, category_evidence, source, norm_hash)"
454
515
  " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
455
- (r.get("uuid"), session_id, pid, ts, (ts or "")[:10], text, len(text),
516
+ (r.get("uuid"), session_id, pid, ts, localtime.day(ts), text, len(text),
456
517
  len(text.split()), cat, conf, json.dumps(ev), src, norm_hash))
457
518
  return cur.lastrowid
458
519
 
@@ -520,7 +581,7 @@ class Loader:
520
581
  " tool_call_count, is_sidechain, agent_id, agent_type, agent_desc)"
521
582
  " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
522
583
  (first.get("uuid"), request_id, session_id, pid,
523
- prompt_id, ts, (ts or "")[:10], (t.hour if t else None), model,
584
+ prompt_id, ts, localtime.day(ts), localtime.hour(ts), model,
524
585
  1 if self.pricing.is_known(model) else 0, first.get("effort"),
525
586
  u.get("service_tier"), msg.get("stop_reason"), inp, out, think, cr,
526
587
  c5, c1, cw, billable, context, cost, cost_no_cache,
@@ -530,7 +591,7 @@ class Loader:
530
591
  else (None, "inline" if first.get("isSidechain") else None, None))))
531
592
  rpk = cur.lastrowid
532
593
 
533
- day = (ts or "")[:10]
594
+ day = localtime.day(ts)
534
595
  for c in tools:
535
596
  name = c.get("name")
536
597
  args = c.get("input") or {}
@@ -570,6 +631,109 @@ class Loader:
570
631
  (session_id, pid, prompt_id, args["file_path"], FILE_TOOLS[name], ts))
571
632
 
572
633
  # ---------- aggregates ----------
634
+ def carry_over(self, prev):
635
+ """Copy sessions whose transcript no longer exists from the previous warehouse.
636
+
637
+ Claude Code deletes old transcripts (cleanupPeriodDays), and a rebuild reads only
638
+ what is on disk, so without this every sync would erase that history. Rows are
639
+ re-derived where the rules may have changed since: cost from the current price
640
+ table, day and hour in local time, and the prompt category. A session whose
641
+ file still exists is never carried (the fresh read wins), and a request already
642
+ loaded from another file is not copied twice.
643
+ """
644
+ d = self.db
645
+ d.execute("ATTACH DATABASE ? AS old", (prev,))
646
+ try:
647
+ d.row_factory = sqlite3.Row
648
+ cols = lambda db, t: [r[1] for r in d.execute(f"PRAGMA {db}.table_info({t})")]
649
+ common = lambda t: [c for c in cols("main", t) if c in set(cols("old", t)) and c != "id"]
650
+ gone = [r for r in d.execute(
651
+ "SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)")
652
+ if r["source_file"] and not os.path.exists(r["source_file"])]
653
+ if not gone:
654
+ return 0
655
+ sids = [r["id"] for r in gone]
656
+ ph = ",".join("?" * len(sids))
657
+
658
+ proj = {}
659
+ for r in d.execute(f"SELECT * FROM old.projects WHERE id IN "
660
+ f"(SELECT project_id FROM old.sessions WHERE id IN ({ph}))", sids):
661
+ hit = d.execute("SELECT id FROM main.projects WHERE slug=?", (r["slug"],)).fetchone()
662
+ if hit:
663
+ proj[r["id"]] = hit[0]
664
+ else:
665
+ c = common("projects")
666
+ cur = d.execute(f"INSERT INTO main.projects ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
667
+ [r[k] for k in c])
668
+ proj[r["id"]] = cur.lastrowid
669
+
670
+ c = common("sessions") + ["id"]
671
+ for r in gone:
672
+ v = {k: r[k] for k in c}
673
+ v["project_id"] = proj.get(r["project_id"], r["project_id"])
674
+ d.execute(f"INSERT INTO main.sessions ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
675
+ [v[k] for k in c])
676
+
677
+ pmap = {}
678
+ c = common("prompts")
679
+ for r in d.execute(f"SELECT * FROM old.prompts WHERE session_id IN ({ph})", sids).fetchall():
680
+ v = {k: r[k] for k in c}
681
+ v["project_id"] = proj.get(r["project_id"], r["project_id"])
682
+ v["day"] = localtime.day(r["ts"])
683
+ if r["text"]:
684
+ v["category"], v["category_confidence"], ev = classify(r["text"])
685
+ v["category_evidence"] = json.dumps(ev)
686
+ cur = d.execute(f"INSERT INTO main.prompts ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
687
+ [v[k] for k in c])
688
+ pmap[r["id"]] = cur.lastrowid
689
+
690
+ rmap = {}
691
+ c = common("requests")
692
+ for r in d.execute(f"SELECT * FROM old.requests WHERE session_id IN ({ph})", sids).fetchall():
693
+ rid = r["request_id"]
694
+ if rid and (rid in self.seen_request_ids or d.execute(
695
+ "SELECT 1 FROM main.requests WHERE request_id=?", (rid,)).fetchone()):
696
+ continue
697
+ v = {k: r[k] for k in c}
698
+ v["project_id"] = proj.get(r["project_id"], r["project_id"])
699
+ v["prompt_id"] = pmap.get(r["prompt_id"])
700
+ v["day"], v["hour"] = localtime.day(r["ts"]), localtime.hour(r["ts"])
701
+ priced = r["priced_as"] or r["model"]
702
+ cost = self.pricing.estimate(priced, r["input_tokens"] or 0, r["output_tokens"] or 0,
703
+ r["cache_read_tokens"] or 0, r["cache_write_5m"] or 0,
704
+ r["cache_write_1h"] or 0)
705
+ no_cache, with_cache = self.pricing.uncached_baseline(
706
+ priced, r["cache_read_tokens"] or 0, r["cache_write_5m"] or 0, r["cache_write_1h"] or 0)
707
+ v["est_cost_usd"], v["est_cost_no_cache_usd"] = cost, cost - with_cache + no_cache
708
+ v["model_known"] = 1 if self.pricing.is_known(r["model"]) else 0
709
+ cur = d.execute(f"INSERT INTO main.requests ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
710
+ [v[k] for k in c])
711
+ rmap[r["id"]] = cur.lastrowid
712
+ if rid:
713
+ self.seen_request_ids.add(rid)
714
+
715
+ c = common("tool_calls")
716
+ for r in d.execute(f"SELECT * FROM old.tool_calls WHERE session_id IN ({ph})", sids).fetchall():
717
+ if r["request_pk"] not in rmap:
718
+ continue
719
+ v = {k: r[k] for k in c}
720
+ v.update(request_pk=rmap[r["request_pk"]], prompt_id=pmap.get(r["prompt_id"]),
721
+ project_id=proj.get(r["project_id"], r["project_id"]), day=localtime.day(r["ts"]))
722
+ d.execute(f"INSERT INTO main.tool_calls ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
723
+ [v[k] for k in c])
724
+
725
+ c = common("files_touched")
726
+ for r in d.execute(f"SELECT * FROM old.files_touched WHERE session_id IN ({ph})", sids).fetchall():
727
+ v = {k: r[k] for k in c}
728
+ v.update(prompt_id=pmap.get(r["prompt_id"]), project_id=proj.get(r["project_id"], r["project_id"]))
729
+ d.execute(f"INSERT INTO main.files_touched ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
730
+ [v[k] for k in c])
731
+ d.commit()
732
+ return len(gone)
733
+ finally:
734
+ d.row_factory = None
735
+ d.execute("DETACH DATABASE old")
736
+
573
737
  def rollup(self):
574
738
  d = self.db
575
739
  d.execute("""
@@ -0,0 +1,412 @@
1
+ """Session guard: a PreToolUse hook that keeps a human in the loop on token-heavy sessions.
2
+
3
+ claude-finops --guard invoked by Claude Code before every tool call
4
+ claude-finops --install-guard wire it into ~/.claude/settings.json (see integrate.py)
5
+
6
+ It counts the session's billable tokens (input + output + cache read + cache
7
+ write, the same figure as the dashboard's Tokens column) straight from the
8
+ transcript, reading only what was appended since the last call. Below the
9
+ budget it warns once at each configured percentage; at the budget it asks the
10
+ user to approve the next tool call, then again every step_pct (or never, in
11
+ "once" mode).
12
+
13
+ A hook cannot end a session, and this one never blocks on its own failure:
14
+ anything unexpected means exit 0 with no output, so Claude Code carries on as
15
+ if the guard were not installed.
16
+ """
17
+ import glob
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+
24
+ from .paths import DATA_DIR, SETTINGS_PATH, LOCAL_SETTINGS_PATH
25
+
26
+ try:
27
+ import fcntl
28
+ except ImportError: # Windows: no advisory locks, concurrent calls are rare there
29
+ fcntl = None
30
+
31
+ STATE_DIR = os.path.join(DATA_DIR, "guard")
32
+ MAX_READ = 64 * 1024 * 1024 # new bytes read per call; the rest waits for the next call
33
+ STALE_S = 30 * 86400
34
+ SWEEP_S = 3600
35
+ SESSION_KEEP_S = 30 * 86400 # a per-session limit is forgotten after this long
36
+ SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
37
+ REJECTED = ("User rejected tool use", "The user doesn't want to proceed with this tool use")
38
+
39
+
40
+ # ---------------------------------------------------------------- settings ----
41
+
42
+ def _num(v):
43
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
44
+
45
+
46
+ def validate_guard(g, default):
47
+ """Clean a guard block. Returns (cleaned, [bad paths]); bad leaves use the default."""
48
+ default = default or {}
49
+ if not isinstance(g, dict):
50
+ return json.loads(json.dumps(default)), (["guard"] if g is not None else [])
51
+ out, bad = dict(default), []
52
+ for k, v in g.items():
53
+ if k.startswith("_"):
54
+ out[k] = v
55
+ elif k == "session_tokens":
56
+ if v is None or (_num(v) and v > 0):
57
+ out[k] = v
58
+ else:
59
+ bad.append(f"guard.{k}")
60
+ elif k == "warn_pct":
61
+ if isinstance(v, list) and all(_num(x) and 0 < x < 100 for x in v):
62
+ out[k] = sorted(set(v))
63
+ else:
64
+ bad.append(f"guard.{k}")
65
+ elif k == "after_approval":
66
+ if v in ("step", "once"):
67
+ out[k] = v
68
+ else:
69
+ bad.append(f"guard.{k}")
70
+ elif k == "step_pct":
71
+ if _num(v) and 1 <= v <= 1000:
72
+ out[k] = v
73
+ else:
74
+ bad.append(f"guard.{k}")
75
+ elif k == "sessions":
76
+ if not isinstance(v, dict):
77
+ bad.append(f"guard.{k}")
78
+ continue
79
+ sessions = {}
80
+ for sid, o in v.items():
81
+ keep = {"set_at": o["set_at"]} if isinstance(o, dict) and isinstance(o.get("set_at"), str) else {}
82
+ if not SAFE_ID.match(str(sid)):
83
+ bad.append(f"guard.sessions.{sid}")
84
+ elif isinstance(o, dict) and o.get("off") is True:
85
+ sessions[sid] = dict(keep, off=True)
86
+ elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
87
+ sessions[sid] = dict(keep, session_tokens=o["session_tokens"])
88
+ else:
89
+ bad.append(f"guard.sessions.{sid}")
90
+ out[k] = sessions
91
+ elif k == "projects":
92
+ if not isinstance(v, dict):
93
+ bad.append(f"guard.{k}")
94
+ continue
95
+ projects = {}
96
+ for path, o in v.items():
97
+ if isinstance(o, dict) and o.get("off") is True:
98
+ projects[path] = {"off": True}
99
+ elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
100
+ projects[path] = {"session_tokens": o["session_tokens"]}
101
+ else:
102
+ bad.append(f"guard.projects.{path}")
103
+ out[k] = projects
104
+ else:
105
+ out[k] = v
106
+ return out, bad
107
+
108
+
109
+ def load_guard_settings():
110
+ """The guard block only: shipped default + settings.local.json, validated.
111
+
112
+ Deliberately not analytics.load_settings(): that also detects the account
113
+ from ~/.claude.json, which the hook has no use for on every tool call.
114
+ """
115
+ with open(SETTINGS_PATH) as fh:
116
+ default = json.load(fh).get("guard") or {}
117
+ local = {}
118
+ try:
119
+ with open(LOCAL_SETTINGS_PATH) as fh:
120
+ local = json.load(fh).get("guard") or {}
121
+ except (OSError, ValueError, AttributeError):
122
+ pass
123
+ merged = dict(default)
124
+ if isinstance(local, dict):
125
+ merged.update(local)
126
+ return validate_guard(merged, default)[0]
127
+
128
+
129
+ def _norm(p):
130
+ return os.path.normcase(os.path.realpath(os.path.expanduser(p)))
131
+
132
+
133
+ def prune_sessions(sessions, now=None):
134
+ """Drop per-session limits set more than 30 days ago (entries without a date stay)."""
135
+ now = now or time.time()
136
+ out = {}
137
+ for sid, o in (sessions or {}).items():
138
+ try:
139
+ age = now - time.mktime(time.strptime(o["set_at"], "%Y-%m-%dT%H:%M:%S"))
140
+ except (KeyError, TypeError, ValueError):
141
+ age = 0
142
+ if age <= SESSION_KEEP_S:
143
+ out[sid] = o
144
+ return out
145
+
146
+
147
+ def has_any_budget(cfg):
148
+ """Is any per-session budget set: global, a project override, or one session's own?"""
149
+ return bool(cfg.get("session_tokens")) or any(
150
+ o.get("session_tokens") for sect in ("projects", "sessions")
151
+ for o in (cfg.get(sect) or {}).values())
152
+
153
+
154
+ def budget_for(cfg, cwd, session_id=None):
155
+ """The budget that applies: this session's own limit, else the most specific
156
+ project override, else the global one."""
157
+ own = (cfg.get("sessions") or {}).get(session_id) if session_id else None
158
+ if own is not None:
159
+ return None if own.get("off") else own.get("session_tokens")
160
+ best, best_len = None, -1
161
+ if cwd:
162
+ here = _norm(cwd)
163
+ for path, o in (cfg.get("projects") or {}).items():
164
+ p = _norm(path)
165
+ if (here == p or here.startswith(p.rstrip(os.sep) + os.sep)) and len(p) > best_len:
166
+ best, best_len = o, len(p)
167
+ if best is not None:
168
+ return None if best.get("off") else best.get("session_tokens")
169
+ return cfg.get("session_tokens")
170
+
171
+
172
+ # ---------------------------------------------------------------- counting ----
173
+
174
+ def billable(u):
175
+ cc = u.get("cache_creation") or {}
176
+ cw = int(u.get("cache_creation_input_tokens") or 0) or (
177
+ int(cc.get("ephemeral_5m_input_tokens") or 0) + int(cc.get("ephemeral_1h_input_tokens") or 0))
178
+ return (int(u.get("input_tokens") or 0) + int(u.get("output_tokens") or 0)
179
+ + int(u.get("cache_read_input_tokens") or 0) + cw)
180
+
181
+
182
+ def session_files(transcript_path, session_id):
183
+ """The transcript plus its subagents' transcripts, which the dashboard counts too."""
184
+ files = [transcript_path]
185
+ sub = os.path.join(os.path.dirname(transcript_path), session_id, "subagents")
186
+ files += sorted(glob.glob(os.path.join(sub, "*.jsonl")))
187
+ return files
188
+
189
+
190
+ def _read_new(path, fs, budget_left, on_row):
191
+ """Feed complete lines appended since fs["offset"] to on_row. Returns bytes read."""
192
+ try:
193
+ size = os.path.getsize(path)
194
+ except OSError:
195
+ return 0
196
+ if size < fs.get("offset", 0): # rewritten: start over
197
+ fs["offset"] = 0
198
+ fs.setdefault("requests", {}).clear()
199
+ if size == fs.get("offset", 0) or budget_left <= 0:
200
+ return 0
201
+ with open(path, "rb") as fh:
202
+ fh.seek(fs.get("offset", 0))
203
+ chunk = fh.read(min(size - fs.get("offset", 0), budget_left))
204
+ end = chunk.rfind(b"\n")
205
+ if end < 0:
206
+ return 0 # a partial line: wait for the rest
207
+ for raw in chunk[:end].split(b"\n"):
208
+ raw = raw.strip()
209
+ if not raw:
210
+ continue
211
+ try:
212
+ row = json.loads(raw)
213
+ except ValueError:
214
+ continue
215
+ if isinstance(row, dict):
216
+ on_row(row)
217
+ fs["offset"] = fs.get("offset", 0) + end + 1
218
+ return end + 1
219
+
220
+
221
+ def count_session(transcript_path, session_id, state, pending_tool=None):
222
+ """Update state["files"] with newly appended lines; return (total billable, tool outcome).
223
+
224
+ Each API request is counted once, from the last line carrying its usage (the
225
+ ETL keys requests the same way: requestId, else message.id, else uuid). The
226
+ outcome is "approved"/"rejected" once the result of pending_tool appears.
227
+ """
228
+ files = state.setdefault("files", {})
229
+ outcome = []
230
+ left = MAX_READ
231
+ for path in session_files(transcript_path, session_id):
232
+ fs = files.setdefault(path, {"offset": 0, "requests": {}})
233
+ reqs = fs.setdefault("requests", {})
234
+
235
+ def on_row(r):
236
+ msg = r.get("message") if isinstance(r.get("message"), dict) else {}
237
+ if r.get("type") == "assistant" and isinstance(msg.get("usage"), dict):
238
+ key = r.get("requestId") or msg.get("id") or r.get("uuid")
239
+ if key:
240
+ reqs[key] = billable(msg["usage"])
241
+ elif pending_tool and r.get("type") == "user":
242
+ for c in msg.get("content") or []:
243
+ if isinstance(c, dict) and c.get("type") == "tool_result" \
244
+ and c.get("tool_use_id") == pending_tool:
245
+ text = json.dumps(c.get("content")) + json.dumps(r.get("toolUseResult"))
246
+ outcome.append("rejected" if any(s in text for s in REJECTED) else "approved")
247
+
248
+ left -= _read_new(path, fs, left, on_row)
249
+ total = sum(sum(f.get("requests", {}).values()) for f in files.values())
250
+ return total, (outcome[-1] if outcome else None)
251
+
252
+
253
+ def session_usage(transcript_path, session_id):
254
+ """The session's billable tokens, counted like the hook does, without saving state."""
255
+ state = {}
256
+ while True:
257
+ before = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
258
+ total, _ = count_session(transcript_path, session_id, state)
259
+ after = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
260
+ if after == before: # nothing new read: the whole session is counted
261
+ return total
262
+
263
+
264
+ def find_transcript(session_id, roots):
265
+ """<root>/<project>/<session_id>.jsonl for a Claude Code session id, or None."""
266
+ if not SAFE_ID.match(str(session_id or "")):
267
+ return None
268
+ for root in roots:
269
+ hits = glob.glob(os.path.join(root, "*", f"{session_id}.jsonl"))
270
+ if hits:
271
+ return hits[0]
272
+ return None
273
+
274
+
275
+ # ---------------------------------------------------------------- deciding ----
276
+
277
+ def fmt_tokens(n):
278
+ for div, unit in ((1e9, "B"), (1e6, "M"), (1e3, "k")):
279
+ if n >= div:
280
+ s = f"{n / div:.1f}".rstrip("0").rstrip(".")
281
+ return f"{s}{unit}"
282
+ return str(int(n))
283
+
284
+
285
+ def next_ask(cfg, state):
286
+ approved = state.get("approved_pct")
287
+ if approved is None:
288
+ return 100
289
+ if cfg.get("after_approval") == "once":
290
+ return None
291
+ return approved + (cfg.get("step_pct") or 25)
292
+
293
+
294
+ def decide(total, budget, cfg, state, outcome=None, tool_use_id=None):
295
+ """The hook's answer for this tool call (a dict to print), or None to stay silent."""
296
+ if not budget:
297
+ return None
298
+ if state.get("asked_at") is not None and outcome == "approved":
299
+ state["approved_pct"], state["asked_at"], state["asked_tool"] = state["asked_at"], None, None
300
+ elif outcome == "rejected":
301
+ state["asked_at"], state["asked_tool"] = None, None # declined: ask again next time
302
+ pct = 100.0 * total / budget
303
+ level = next_ask(cfg, state)
304
+ if level is not None and pct >= level:
305
+ state["asked_at"], state["asked_tool"] = level, tool_use_id
306
+ # the ask supersedes any warning it jumped past; don't replay them after approval
307
+ state["warned"] = sorted(set(state.get("warned") or [])
308
+ | {w for w in (cfg.get("warn_pct") or []) if pct >= w})
309
+ after = next_ask(cfg, dict(state, approved_pct=level))
310
+ hint = (f"Approving continues until {after:g}%." if after is not None
311
+ else "Approving turns the guard off for the rest of this session.")
312
+ return {"hookSpecificOutput": {
313
+ "hookEventName": "PreToolUse",
314
+ "permissionDecision": "ask",
315
+ "permissionDecisionReason":
316
+ f"Session guard: this session has used {fmt_tokens(total)} tokens, "
317
+ f"{pct:.0f}% of its {fmt_tokens(budget)} budget. Allow this tool call? {hint}"}}
318
+ warned = set(state.get("warned") or [])
319
+ crossed = [w for w in (cfg.get("warn_pct") or []) if pct >= w and w not in warned]
320
+ if crossed:
321
+ state["warned"] = sorted(warned | set(crossed))
322
+ return {"systemMessage": f"Session guard: {fmt_tokens(total)} tokens, {pct:.0f}% of "
323
+ f"this session's {fmt_tokens(budget)} budget.",
324
+ "hookSpecificOutput": {
325
+ "hookEventName": "PreToolUse",
326
+ "additionalContext":
327
+ f"This session has used {pct:.0f}% of its token budget. At a natural "
328
+ "break, suggest the user run /compact or start a fresh session."}}
329
+ return None
330
+
331
+
332
+ # ------------------------------------------------------------------- state ----
333
+
334
+ def _state_path(session_id):
335
+ safe = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_")
336
+ return os.path.join(STATE_DIR, f"{safe or 'unknown'}.json")
337
+
338
+
339
+ def _load_state(path):
340
+ try:
341
+ with open(path) as fh:
342
+ s = json.load(fh)
343
+ return s if isinstance(s, dict) else {}
344
+ except (OSError, ValueError):
345
+ return {}
346
+
347
+
348
+ def _save_state(path, state):
349
+ state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
350
+ tmp = f"{path}.{os.getpid()}.tmp"
351
+ with open(tmp, "w") as fh:
352
+ json.dump(state, fh)
353
+ os.replace(tmp, path)
354
+
355
+
356
+ def _sweep():
357
+ """Drop state for sessions untouched for 30 days; at most once an hour."""
358
+ marker = os.path.join(STATE_DIR, ".swept")
359
+ now = time.time()
360
+ try:
361
+ if now - os.path.getmtime(marker) < SWEEP_S:
362
+ return
363
+ except OSError:
364
+ pass
365
+ for p in glob.glob(os.path.join(STATE_DIR, "*.json")) + glob.glob(os.path.join(STATE_DIR, "*.lock")):
366
+ try:
367
+ if now - os.path.getmtime(p) > STALE_S:
368
+ os.remove(p)
369
+ except OSError:
370
+ pass
371
+ with open(marker, "w"):
372
+ pass
373
+
374
+
375
+ # -------------------------------------------------------------------- hook ----
376
+
377
+ def run(payload, cfg=None):
378
+ """Decide for one PreToolUse payload. Returns the dict to print, or None."""
379
+ session_id = payload.get("session_id")
380
+ transcript = payload.get("transcript_path")
381
+ if not session_id or not transcript:
382
+ return None
383
+ cfg = cfg if cfg is not None else load_guard_settings()
384
+ budget = budget_for(cfg, payload.get("cwd"), session_id)
385
+ if not budget:
386
+ return None
387
+ os.makedirs(STATE_DIR, exist_ok=True)
388
+ path = _state_path(session_id)
389
+ lock = open(path + ".lock", "w")
390
+ try:
391
+ if fcntl:
392
+ fcntl.flock(lock, fcntl.LOCK_EX) # parallel tool calls share one state file
393
+ state = _load_state(path)
394
+ total, outcome = count_session(transcript, session_id, state, state.get("asked_tool"))
395
+ out = decide(total, budget, cfg, state, outcome, payload.get("tool_use_id"))
396
+ _save_state(path, state)
397
+ finally:
398
+ lock.close()
399
+ _sweep()
400
+ return out
401
+
402
+
403
+ def main():
404
+ try:
405
+ raw = sys.stdin.read()
406
+ payload = json.loads(raw) if raw.strip() else {}
407
+ out = run(payload) if isinstance(payload, dict) else None
408
+ if out:
409
+ print(json.dumps(out))
410
+ except Exception:
411
+ pass # fail open: never stand between the user and their session
412
+ return 0