raidlock 0.1.0__py3-none-any.whl

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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/limits.py ADDED
@@ -0,0 +1,685 @@
1
+ import contextlib
2
+ import datetime
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+
12
+ from raidlock.core import clock, registry, store
13
+
14
+ CLAUDE_DOC = "https://code.claude.com/docs/en/statusline"
15
+ FILES_SCANNED = 5
16
+ TAIL_BYTES = 262144
17
+ STALE_AFTER = 6 * 3600
18
+ FRESH_FOR = 600
19
+ SESSIONS_KEPT = 20
20
+ DEFAULT_WINDOW = 200000
21
+ EXTENDED_WINDOW = 1000000
22
+ HOOK_EVERY = 60
23
+ STATUSLINE_REFRESH = 300
24
+ CHAINED_TIMEOUT = 5
25
+ PROMPT_FIELDS = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
26
+
27
+
28
+ def codex_home():
29
+ return os.path.abspath(os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex"))
30
+
31
+
32
+ def window_label(minutes):
33
+ if not isinstance(minutes, (int, float)) or minutes <= 0:
34
+ return None
35
+ if minutes % 10080 == 0:
36
+ weeks = int(minutes // 10080)
37
+ return "weekly" if weeks == 1 else f"{weeks}w"
38
+ if minutes % 1440 == 0:
39
+ return f"{int(minutes // 1440)}d"
40
+ if minutes % 60 == 0:
41
+ return f"{int(minutes // 60)}h"
42
+ return f"{int(minutes)}m"
43
+
44
+
45
+ def iso_epoch(value):
46
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
47
+ return None
48
+ try:
49
+ return datetime.datetime.fromtimestamp(value, datetime.UTC).strftime(clock.FORMAT)
50
+ except (OverflowError, OSError, ValueError):
51
+ return None
52
+
53
+
54
+ def number(value):
55
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
56
+
57
+
58
+ def tail_lines(path, size=TAIL_BYTES):
59
+ with open(path, "rb") as fh:
60
+ fh.seek(0, os.SEEK_END)
61
+ end = fh.tell()
62
+ start = max(0, end - size)
63
+ fh.seek(start)
64
+ data = fh.read()
65
+ lines = data.split(b"\n")
66
+ if start > 0:
67
+ lines = lines[1:]
68
+ return lines
69
+
70
+
71
+ def newest_session_files(root, count=FILES_SCANNED):
72
+ found = []
73
+ for base, _dirs, names in os.walk(root):
74
+ for name in names:
75
+ if name.endswith(".jsonl"):
76
+ path = os.path.join(base, name)
77
+ try:
78
+ found.append((os.stat(path).st_mtime, path))
79
+ except OSError:
80
+ continue
81
+ found.sort(reverse=True)
82
+ return [path for _mtime, path in found[:count]]
83
+
84
+
85
+ def find_rate_limits(obj):
86
+ if not isinstance(obj, dict):
87
+ return None
88
+ if isinstance(obj.get("rate_limits"), dict):
89
+ return obj["rate_limits"]
90
+ payload = obj.get("payload")
91
+ if isinstance(payload, dict):
92
+ if isinstance(payload.get("rate_limits"), dict):
93
+ return payload["rate_limits"]
94
+ info = payload.get("info")
95
+ if isinstance(info, dict) and isinstance(info.get("rate_limits"), dict):
96
+ return info["rate_limits"]
97
+ return None
98
+
99
+
100
+ def record_time(obj, path):
101
+ ts = obj.get("timestamp") if isinstance(obj, dict) else None
102
+ if isinstance(ts, str):
103
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
104
+ try:
105
+ return datetime.datetime.strptime(ts, fmt).replace(tzinfo=datetime.UTC).timestamp()
106
+ except ValueError:
107
+ continue
108
+ try:
109
+ return os.stat(path).st_mtime
110
+ except OSError:
111
+ return None
112
+
113
+
114
+ def codex_window(entry, now):
115
+ if not isinstance(entry, dict):
116
+ return None
117
+ pct = number(entry.get("used_percent"))
118
+ if pct is None:
119
+ return None
120
+ resets = number(entry.get("resets_at"))
121
+ if resets is not None and resets <= now:
122
+ return None
123
+ return {
124
+ "window": window_label(number(entry.get("window_minutes"))),
125
+ "window_minutes": number(entry.get("window_minutes")),
126
+ "used_percent": pct,
127
+ "resets_at": iso_epoch(resets),
128
+ "resets_in_seconds": max(0, int(resets - now)) if resets is not None else None,
129
+ }
130
+
131
+
132
+ def percent(used, size):
133
+ if used is None or not size or size <= 0:
134
+ return None
135
+ return round(min(100.0, max(0.0, 100.0 * used / size)), 1)
136
+
137
+
138
+ def codex_context(obj):
139
+ payload = obj.get("payload") if isinstance(obj, dict) else None
140
+ info = payload.get("info") if isinstance(payload, dict) else None
141
+ if not isinstance(info, dict):
142
+ return None
143
+ last = info.get("last_token_usage")
144
+ size = number(info.get("model_context_window"))
145
+ used = number(last.get("input_tokens")) if isinstance(last, dict) else None
146
+ pct = percent(used, size)
147
+ if pct is None:
148
+ return None
149
+ return {"used_percent": pct, "used_tokens": int(used), "window_tokens": int(size), "source": "codex session log"}
150
+
151
+
152
+ def freshness(base, seen, now):
153
+ age = max(0, int(now - seen)) if seen is not None else None
154
+ return {
155
+ **base,
156
+ "status": "ok" if age is None or age <= STALE_AFTER else "stale",
157
+ "fresh": age is not None and age <= FRESH_FOR,
158
+ "observed_at": iso_epoch(seen),
159
+ "age_seconds": age,
160
+ }
161
+
162
+
163
+ def codex_limits(now=None):
164
+ now = time.time() if now is None else now
165
+ root = os.path.join(codex_home(), "sessions")
166
+ base = {"provider": "codex", "source": "codex session logs"}
167
+ if not os.path.isdir(root):
168
+ return {**base, "status": "unavailable", "detail": "no Codex sessions found"}
169
+ for path in newest_session_files(root):
170
+ try:
171
+ lines = tail_lines(path)
172
+ except OSError:
173
+ continue
174
+ for raw in reversed(lines):
175
+ if b"rate_limits" not in raw:
176
+ continue
177
+ try:
178
+ obj = json.loads(raw)
179
+ except ValueError:
180
+ continue
181
+ rl = find_rate_limits(obj)
182
+ if not rl:
183
+ continue
184
+ seen = record_time(obj, path)
185
+ windows = [w for w in (codex_window(rl.get("primary"), now), codex_window(rl.get("secondary"), now)) if w]
186
+ credits = rl.get("credits") if isinstance(rl.get("credits"), dict) else {}
187
+ reached = rl.get("rate_limit_reached_type")
188
+ return freshness(
189
+ {
190
+ **base,
191
+ "limit_id": rl.get("limit_id") if isinstance(rl.get("limit_id"), str) else None,
192
+ "plan": rl.get("plan_type") if isinstance(rl.get("plan_type"), str) else None,
193
+ "windows": windows,
194
+ "context": codex_context(obj),
195
+ "limit_reached": reached if isinstance(reached, str) else None,
196
+ "credits": {
197
+ "has_credits": bool(credits.get("has_credits")),
198
+ "unlimited": bool(credits.get("unlimited")),
199
+ }
200
+ if credits
201
+ else None,
202
+ },
203
+ seen,
204
+ now,
205
+ )
206
+ return {**base, "status": "unavailable", "detail": "no rate_limits record in the newest sessions"}
207
+
208
+
209
+ def claude_snapshot_path():
210
+ return os.path.join(registry.home(), "claude-limits.json")
211
+
212
+
213
+ def claude_settings_path():
214
+ root = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
215
+ return os.path.join(os.path.abspath(root), "settings.json")
216
+
217
+
218
+ def statusline_state_path():
219
+ return os.path.join(registry.home(), "claude-statusline.json")
220
+
221
+
222
+ def claude_windows(rl, now):
223
+ windows = []
224
+ for key, label, minutes in (("five_hour", "5h", 300), ("seven_day", "weekly", 10080)):
225
+ entry = rl.get(key)
226
+ if not isinstance(entry, dict):
227
+ continue
228
+ pct = number(entry.get("used_percentage"))
229
+ if pct is None:
230
+ continue
231
+ resets = number(entry.get("resets_at"))
232
+ if resets is not None and resets <= now:
233
+ continue
234
+ windows.append(
235
+ {
236
+ "window": label,
237
+ "window_minutes": minutes,
238
+ "used_percent": pct,
239
+ "resets_at": iso_epoch(resets),
240
+ "resets_in_seconds": max(0, int(resets - now)) if resets is not None else None,
241
+ }
242
+ )
243
+ return windows
244
+
245
+
246
+ def session_key(session_id):
247
+ return hashlib.sha256(str(session_id or "").encode("utf-8")).hexdigest()[:12]
248
+
249
+
250
+ def load_snapshot():
251
+ try:
252
+ with open(claude_snapshot_path(), encoding="utf-8") as fh:
253
+ snap = json.load(fh)
254
+ except (OSError, ValueError):
255
+ return {"schema": 3, "sessions": {}}
256
+ if not isinstance(snap, dict):
257
+ return {"schema": 3, "sessions": {}}
258
+ if snap.get("schema") == 2 and isinstance(snap.get("rate_limits"), dict):
259
+ return {"schema": 3, "rate_limits": snap["rate_limits"], "rate_observed": snap.get("observed"), "sessions": {}}
260
+ if not isinstance(snap.get("sessions"), dict):
261
+ snap["sessions"] = {}
262
+ return snap
263
+
264
+
265
+ def save_snapshot(snap):
266
+ sessions = snap.get("sessions") or {}
267
+ keep = sorted(sessions.items(), key=lambda kv: -(number(kv[1].get("observed")) or 0))[:SESSIONS_KEPT]
268
+ snap = {**snap, "schema": 3, "sessions": dict(keep)}
269
+ path = claude_snapshot_path()
270
+ os.makedirs(os.path.dirname(path), exist_ok=True)
271
+ store.atomic_write_text(path, json.dumps(snap, sort_keys=True))
272
+ with contextlib.suppress(OSError):
273
+ os.chmod(path, 0o600)
274
+
275
+
276
+ def statusline_rates(rl):
277
+ out = {}
278
+ for key in ("five_hour", "seven_day"):
279
+ entry = rl.get(key) if isinstance(rl, dict) else None
280
+ if isinstance(entry, dict) and number(entry.get("used_percentage")) is not None:
281
+ out[key] = {"used_percentage": entry["used_percentage"], "resets_at": number(entry.get("resets_at"))}
282
+ return out
283
+
284
+
285
+ def statusline_session(data, now):
286
+ ctx = data.get("context_window") if isinstance(data.get("context_window"), dict) else {}
287
+ cost = data.get("cost") if isinstance(data.get("cost"), dict) else {}
288
+ model = data.get("model") if isinstance(data.get("model"), dict) else {}
289
+ row = {
290
+ "observed": now,
291
+ "source": "statusline",
292
+ "context_percent": number(ctx.get("used_percentage")),
293
+ "context_window": number(ctx.get("context_window_size")),
294
+ "cost_usd": number(cost.get("total_cost_usd")),
295
+ "duration_ms": number(cost.get("total_duration_ms")),
296
+ "model": model.get("id") if isinstance(model.get("id"), str) else None,
297
+ }
298
+ if isinstance(data.get("transcript_path"), str):
299
+ row["transcript"] = data["transcript_path"]
300
+ return row if any(row[k] is not None for k in ("context_percent", "cost_usd", "duration_ms")) else None
301
+
302
+
303
+ def ingest_claude(text, now=None):
304
+ """Record a Claude Code statusline input: plan windows, and the session's context, cost and duration."""
305
+ now = time.time() if now is None else now
306
+ try:
307
+ data = json.loads(text)
308
+ except ValueError:
309
+ return False
310
+ if not isinstance(data, dict):
311
+ return False
312
+ rates = statusline_rates(data.get("rate_limits"))
313
+ row = statusline_session(data, now)
314
+ if not rates and not row:
315
+ return False
316
+ snap = load_snapshot()
317
+ if rates:
318
+ snap["rate_limits"] = rates
319
+ snap["rate_observed"] = now
320
+ if row:
321
+ snap["sessions"][session_key(data.get("session_id"))] = row
322
+ save_snapshot(snap)
323
+ return True
324
+
325
+
326
+ def context_windows(home=None):
327
+ try:
328
+ data = store.read_json(os.path.join(home or registry.home(), "pricing.json")) or {}
329
+ except Exception:
330
+ data = {}
331
+ out = {}
332
+ for model, rates in (data.get("models") or {}).items() if isinstance(data, dict) else ():
333
+ value = (rates or {}).get("context_window") if isinstance(rates, dict) else None
334
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0:
335
+ out[model] = value
336
+ return out
337
+
338
+
339
+ def transcript_usage(path):
340
+ """The prompt side of the newest assistant turn in a Claude Code transcript: (tokens, model, mtime)."""
341
+ try:
342
+ mtime = os.stat(path).st_mtime
343
+ lines = tail_lines(path)
344
+ except OSError:
345
+ return None
346
+ for raw in reversed(lines):
347
+ if b'"usage"' not in raw or b'"assistant"' not in raw:
348
+ continue
349
+ try:
350
+ rec = json.loads(raw)
351
+ except ValueError:
352
+ continue
353
+ msg = rec.get("message") if isinstance(rec, dict) else None
354
+ usage = msg.get("usage") if isinstance(msg, dict) else None
355
+ if not isinstance(usage, dict) or rec.get("isSidechain"):
356
+ continue
357
+ parts = [number(usage.get(k)) for k in PROMPT_FIELDS]
358
+ if all(p is None for p in parts):
359
+ continue
360
+ model = msg.get("model") if isinstance(msg.get("model"), str) and not msg["model"].startswith("<") else None
361
+ return int(sum(p or 0 for p in parts)), model, mtime
362
+ return None
363
+
364
+
365
+ def transcript_row(path, prior, now):
366
+ found = transcript_usage(path)
367
+ if not found:
368
+ return None
369
+ tokens, model, mtime = found
370
+ model = model or (prior or {}).get("model")
371
+ known = number((prior or {}).get("context_window"))
372
+ window = known or context_windows().get(model or "")
373
+ assumed = window or (EXTENDED_WINDOW if tokens > DEFAULT_WINDOW else DEFAULT_WINDOW)
374
+ row = {
375
+ "observed": min(now, mtime),
376
+ "source": "transcript",
377
+ "context_tokens": tokens,
378
+ "context_window": assumed,
379
+ "context_assumed": not window,
380
+ "context_percent": percent(tokens, assumed),
381
+ "model": model,
382
+ "transcript": path,
383
+ }
384
+ for k in ("cost_usd", "duration_ms"):
385
+ if (prior or {}).get(k) is not None:
386
+ row[k] = prior[k]
387
+ return row
388
+
389
+
390
+ def ingest_transcript(data, now=None):
391
+ """Hook path: record context use from the transcript the hook payload names, at most once a minute a session."""
392
+ now = time.time() if now is None else now
393
+ path = data.get("transcript_path") if isinstance(data, dict) else None
394
+ if not isinstance(path, str) or not path.endswith(".jsonl") or not os.path.isfile(path):
395
+ return False
396
+ snap = load_snapshot()
397
+ key = session_key(data.get("session_id"))
398
+ prior = snap["sessions"].get(key)
399
+ if prior and now - (number(prior.get("observed")) or 0) < HOOK_EVERY:
400
+ return False
401
+ row = transcript_row(path, prior, now)
402
+ if not row:
403
+ return False
404
+ snap["sessions"][key] = row
405
+ save_snapshot(snap)
406
+ return True
407
+
408
+
409
+ def refresh_sessions(snap, now):
410
+ """Re-read the newest session's transcript when its record is over FRESH_FOR old and the transcript moved on."""
411
+ sessions = snap.get("sessions") or {}
412
+ if not sessions:
413
+ return snap
414
+ key, row = max(sessions.items(), key=lambda kv: number(kv[1].get("observed")) or 0)
415
+ path = row.get("transcript")
416
+ if not isinstance(path, str) or now - (number(row.get("observed")) or 0) <= FRESH_FOR:
417
+ return snap
418
+ try:
419
+ moved = os.stat(path).st_mtime > (number(row.get("observed")) or 0)
420
+ except OSError:
421
+ return snap
422
+ if not moved:
423
+ return snap
424
+ fresh = transcript_row(path, row, now)
425
+ if fresh:
426
+ snap["sessions"][key] = fresh
427
+ with contextlib.suppress(OSError):
428
+ save_snapshot(snap)
429
+ return snap
430
+
431
+
432
+ def statusline_installed():
433
+ try:
434
+ with open(claude_settings_path(), encoding="utf-8") as fh:
435
+ line = (json.load(fh) or {}).get("statusLine") or {}
436
+ except (OSError, ValueError, AttributeError):
437
+ return False
438
+ return isinstance(line, dict) and "limits --statusline" in str(line.get("command", ""))
439
+
440
+
441
+ def claude_limits(now=None):
442
+ now = time.time() if now is None else now
443
+ base = {"provider": "claude", "source": "Claude Code statusline and session transcript", "doc": CLAUDE_DOC}
444
+ snap = refresh_sessions(load_snapshot(), now)
445
+ rates = snap.get("rate_limits") if isinstance(snap.get("rate_limits"), dict) else {}
446
+ windows = claude_windows(rates, now)
447
+ sessions = snap.get("sessions") or {}
448
+ newest = max(sessions.values(), key=lambda r: number(r.get("observed")) or 0) if sessions else None
449
+ if not windows and not newest:
450
+ return {
451
+ **base,
452
+ "status": "not exposed",
453
+ "detail": "No Claude Code session recorded yet. With the raidlock plugin, context use is recorded "
454
+ "on the next tool call; plan usage (5h and weekly) comes only from the statusline: "
455
+ "run `raid limits --install-statusline` once.",
456
+ }
457
+ seen = [number(snap.get("rate_observed")) if windows else None, number((newest or {}).get("observed"))]
458
+ seen = max((s for s in seen if s is not None), default=None)
459
+ out = {**base, "plan": None, "windows": windows}
460
+ if newest:
461
+ ctx = None
462
+ if newest.get("context_percent") is not None:
463
+ ctx = {
464
+ "used_percent": newest["context_percent"],
465
+ "used_tokens": newest.get("context_tokens"),
466
+ "window_tokens": number(newest.get("context_window")),
467
+ "assumed_window": bool(newest.get("context_assumed")),
468
+ "source": newest.get("source", "statusline"),
469
+ }
470
+ out["context"] = ctx
471
+ out["session"] = {
472
+ k: newest[k] for k in ("cost_usd", "duration_ms", "model") if newest.get(k) is not None
473
+ } or None
474
+ out["sessions_recent"] = sum(
475
+ 1 for r in sessions.values() if now - (number(r.get("observed")) or 0) <= FRESH_FOR
476
+ )
477
+ return freshness(out, seen, now)
478
+
479
+
480
+ def gemini_limits():
481
+ return {"provider": "gemini", "status": "not supported", "detail": "no local source for Gemini plan limits"}
482
+
483
+
484
+ def collect(now=None):
485
+ return {"providers": [codex_limits(now), claude_limits(now), gemini_limits()]}
486
+
487
+
488
+ def human_age(seconds):
489
+ if seconds is None:
490
+ return "at an unknown time"
491
+ if seconds < 90:
492
+ return f"{seconds}s ago"
493
+ if seconds < 5400:
494
+ return f"{seconds // 60} min ago"
495
+ if seconds < 172800:
496
+ return f"{seconds // 3600} h ago"
497
+ return f"{seconds // 86400} d ago"
498
+
499
+
500
+ def human_reset(seconds):
501
+ if seconds is None:
502
+ return "reset time not reported"
503
+ if seconds < 5400:
504
+ return f"resets in {max(1, seconds // 60)} min"
505
+ if seconds < 172800:
506
+ return f"resets in {seconds // 3600} h"
507
+ return f"resets in {seconds // 86400} d"
508
+
509
+
510
+ def render(result):
511
+ lines = []
512
+ for p in result["providers"]:
513
+ name = p["provider"]
514
+ if p["status"] in ("ok", "stale"):
515
+ plan = f" ({p['plan']})" if p.get("plan") else ""
516
+ lines.append(f"{name}{plan}: updated {human_age(p.get('age_seconds'))}")
517
+ ctx = p.get("context")
518
+ if ctx:
519
+ note = ", assumed window" if ctx.get("assumed_window") else ""
520
+ lines.append(f" context: {ctx['used_percent']:g}% used{note}")
521
+ for w in p.get("windows") or []:
522
+ lines.append(
523
+ f" {w['window'] or 'window'}: {w['used_percent']:g}% used, {human_reset(w['resets_in_seconds'])}"
524
+ )
525
+ sess = p.get("session") or {}
526
+ if sess.get("cost_usd") is not None:
527
+ lines.append(f" session cost (Claude Code estimate): ${sess['cost_usd']:.2f}")
528
+ if p.get("limit_reached"):
529
+ lines.append(f" limit reached: {p['limit_reached']}")
530
+ else:
531
+ lines.append(f"{name}: {p['status']}" + (f" ({p['detail']})" if p.get("detail") else ""))
532
+ return "\n".join(lines)
533
+
534
+
535
+ def raid_command():
536
+ found = shutil.which("raid") or shutil.which("raidlock")
537
+ if found:
538
+ return shlex.quote(found)
539
+ return f"{shlex.quote(sys.executable)} -m raidlock"
540
+
541
+
542
+ def ours(line):
543
+ return isinstance(line, dict) and "limits --statusline" in str(line.get("command", ""))
544
+
545
+
546
+ def read_settings(path):
547
+ try:
548
+ with open(path, encoding="utf-8") as fh:
549
+ data = json.load(fh)
550
+ except FileNotFoundError:
551
+ return {}
552
+ if not isinstance(data, dict):
553
+ raise ValueError(f"{path} is not a JSON object")
554
+ return data
555
+
556
+
557
+ def install_statusline():
558
+ """Point Claude Code's statusLine at `raid limits --statusline`, keeping the previous command chained."""
559
+ path = claude_settings_path()
560
+ settings = read_settings(path)
561
+ current = settings.get("statusLine")
562
+ if ours(current):
563
+ return {"installed": True, "changed": False, "settings": path}
564
+ store.atomic_write_text(statusline_state_path(), json.dumps({"previous": current}, sort_keys=True))
565
+ line = {"type": "command", "command": f"{raid_command()} limits --statusline"}
566
+ if isinstance(current, dict):
567
+ for k in ("padding", "refreshInterval"):
568
+ if k in current:
569
+ line[k] = current[k]
570
+ line.setdefault("refreshInterval", STATUSLINE_REFRESH)
571
+ settings["statusLine"] = line
572
+ store.atomic_write_text(path, json.dumps(settings, indent=2) + "\n")
573
+ return {"installed": True, "changed": True, "settings": path, "chained": bool(current)}
574
+
575
+
576
+ def uninstall_statusline():
577
+ path = claude_settings_path()
578
+ settings = read_settings(path)
579
+ if not ours(settings.get("statusLine")):
580
+ return {"installed": False, "changed": False, "settings": path}
581
+ try:
582
+ with open(statusline_state_path(), encoding="utf-8") as fh:
583
+ previous = json.load(fh).get("previous")
584
+ except (OSError, ValueError, AttributeError):
585
+ previous = None
586
+ if previous:
587
+ settings["statusLine"] = previous
588
+ else:
589
+ settings.pop("statusLine", None)
590
+ store.atomic_write_text(path, json.dumps(settings, indent=2) + "\n")
591
+ with contextlib.suppress(OSError):
592
+ os.remove(statusline_state_path())
593
+ return {"installed": False, "changed": True, "settings": path, "restored": bool(previous)}
594
+
595
+
596
+ def previous_command():
597
+ try:
598
+ with open(statusline_state_path(), encoding="utf-8") as fh:
599
+ prev = json.load(fh).get("previous")
600
+ except (OSError, ValueError, AttributeError):
601
+ return None
602
+ if isinstance(prev, dict) and isinstance(prev.get("command"), str) and not ours(prev):
603
+ return prev["command"]
604
+ return None
605
+
606
+
607
+ def own_line(text):
608
+ try:
609
+ data = json.loads(text)
610
+ except ValueError:
611
+ return ""
612
+ if not isinstance(data, dict):
613
+ return ""
614
+ bits = []
615
+ ctx = data.get("context_window") if isinstance(data.get("context_window"), dict) else {}
616
+ if number(ctx.get("used_percentage")) is not None:
617
+ bits.append(f"ctx {ctx['used_percentage']:.0f}%")
618
+ for key, label in (("five_hour", "5h"), ("seven_day", "wk")):
619
+ entry = (data.get("rate_limits") or {}).get(key) if isinstance(data.get("rate_limits"), dict) else None
620
+ if isinstance(entry, dict) and number(entry.get("used_percentage")) is not None:
621
+ bits.append(f"{label} {entry['used_percentage']:.0f}%")
622
+ return " · ".join(bits)
623
+
624
+
625
+ def statusline(text):
626
+ """Statusline entry point: record the input, then print the chained command's output or a short line."""
627
+ with contextlib.suppress(OSError, ValueError, TypeError):
628
+ ingest_claude(text)
629
+ command = previous_command()
630
+ if command:
631
+ try:
632
+ done = subprocess.run(
633
+ [shutil.which("sh") or "/bin/sh", "-c", command],
634
+ input=text,
635
+ capture_output=True,
636
+ text=True,
637
+ timeout=CHAINED_TIMEOUT,
638
+ )
639
+ return done.stdout
640
+ except (OSError, subprocess.SubprocessError):
641
+ return ""
642
+ return own_line(text)
643
+
644
+
645
+ def register_cli(add_command):
646
+ def setup(p):
647
+ p.add_argument(
648
+ "--ingest-claude",
649
+ action="store_true",
650
+ help="read Claude Code statusline JSON on stdin and record plan windows, context, cost and duration",
651
+ )
652
+ p.add_argument(
653
+ "--statusline",
654
+ action="store_true",
655
+ help="Claude Code statusLine command: record the input, then print the previous statusline's output",
656
+ )
657
+ p.add_argument(
658
+ "--install-statusline",
659
+ action="store_true",
660
+ help="set Claude Code's statusLine to raid limits --statusline, chaining the current one",
661
+ )
662
+ p.add_argument(
663
+ "--uninstall-statusline", action="store_true", help="restore the statusLine that was there before"
664
+ )
665
+
666
+ def handler(ctx, a):
667
+ if a.statusline:
668
+ sys.stdout.write(statusline(sys.stdin.read()))
669
+ sys.stdout.flush()
670
+ return {}, ""
671
+ if a.install_statusline:
672
+ r = install_statusline()
673
+ return r, ("statusline installed in " if r["changed"] else "statusline already installed in ") + r[
674
+ "settings"
675
+ ]
676
+ if a.uninstall_statusline:
677
+ r = uninstall_statusline()
678
+ return r, ("statusline restored in " if r["changed"] else "no raidlock statusline in ") + r["settings"]
679
+ if a.ingest_claude:
680
+ ok = ingest_claude(sys.stdin.read())
681
+ return {"recorded": ok}, "recorded" if ok else "nothing to record in input"
682
+ result = collect()
683
+ return result, render(result)
684
+
685
+ add_command("limits", "plan limits and context use per provider (Codex, Claude Code, Gemini)", setup, handler)