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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- 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)
|