agenttelemetry-app 2.0.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.
- agent_telemetry/__init__.py +3 -0
- agent_telemetry/__main__.py +7 -0
- agent_telemetry/accounts.py +355 -0
- agent_telemetry/chart.umd.min.js +20 -0
- agent_telemetry/cli.py +47 -0
- agent_telemetry/dashboard.py +2413 -0
- agent_telemetry/index.html +465 -0
- agent_telemetry/manifest.json +22 -0
- agent_telemetry/native.py +135 -0
- agent_telemetry/parser.py +4202 -0
- agent_telemetry/session_names.py +270 -0
- agent_telemetry/static/app.css +650 -0
- agent_telemetry/static/apple-touch-icon.png +0 -0
- agent_telemetry/static/charts.js +330 -0
- agent_telemetry/static/core.js +522 -0
- agent_telemetry/static/icon-192.png +0 -0
- agent_telemetry/static/icon-512.png +0 -0
- agent_telemetry/static/tool-icons/LICENSE.hermes.txt +21 -0
- agent_telemetry/static/tool-icons/LICENSE.openclaw.txt +21 -0
- agent_telemetry/static/tool-icons/LICENSE.simple-icons.txt +30 -0
- agent_telemetry/static/tool-icons/README.md +21 -0
- agent_telemetry/static/tool-icons/claude.svg +1 -0
- agent_telemetry/static/tool-icons/copilot.svg +1 -0
- agent_telemetry/static/tool-icons/cursor.svg +1 -0
- agent_telemetry/static/tool-icons/hermes.svg +7 -0
- agent_telemetry/static/tool-icons/openai.svg +1 -0
- agent_telemetry/static/tool-icons/openclaw.svg +18 -0
- agent_telemetry/static/tool-icons/opencode.svg +1 -0
- agent_telemetry/static/views.js +2749 -0
- agent_telemetry/sw.js +70 -0
- agenttelemetry_app-2.0.0.dist-info/METADATA +495 -0
- agenttelemetry_app-2.0.0.dist-info/RECORD +36 -0
- agenttelemetry_app-2.0.0.dist-info/WHEEL +5 -0
- agenttelemetry_app-2.0.0.dist-info/entry_points.txt +4 -0
- agenttelemetry_app-2.0.0.dist-info/licenses/LICENSE +21 -0
- agenttelemetry_app-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Private account metadata and account-wide quota snapshots. Stdlib only.
|
|
2
|
+
|
|
3
|
+
No credential decoding, background network lookup, or quota arithmetic from tokens.
|
|
4
|
+
The parser stores only allowlisted observations; peer exports exclude this module's
|
|
5
|
+
metadata. Live CLI queries run only after an explicit dashboard action.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
import queue
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
if __package__:
|
|
22
|
+
from . import native as N
|
|
23
|
+
else:
|
|
24
|
+
import native as N
|
|
25
|
+
|
|
26
|
+
STALE_AFTER = 300
|
|
27
|
+
METADATA_VERSION = 1
|
|
28
|
+
_lock = threading.Lock()
|
|
29
|
+
_live = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AccountChanged(ValueError):
|
|
33
|
+
"""A lookup cannot safely retain the previous account's figures."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def text(value):
|
|
37
|
+
if not isinstance(value, str) or not value or len(value) > 240:
|
|
38
|
+
return None
|
|
39
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
40
|
+
return None
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def number(value, minimum=0, maximum=None):
|
|
45
|
+
if type(value) not in (int, float) or (type(value) is float and not math.isfinite(value)):
|
|
46
|
+
return None
|
|
47
|
+
if value < minimum or (maximum is not None and value > maximum):
|
|
48
|
+
return None
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def timestamp(value):
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
try:
|
|
55
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
56
|
+
if parsed.tzinfo is None: return None
|
|
57
|
+
value = parsed.timestamp()
|
|
58
|
+
except (ValueError, OverflowError): return None
|
|
59
|
+
return number(value, 1, 253402300799)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def window(value, label, minutes=None, camel=False):
|
|
63
|
+
if not isinstance(value, dict): return None
|
|
64
|
+
used = number(value.get("usedPercent" if camel else "used_percent"), 0, 100)
|
|
65
|
+
reset = timestamp(value.get("resetsAt" if camel else "resets_at"))
|
|
66
|
+
duration = number(value.get("windowDurationMins" if camel else "window_minutes"), 1, 52560000)
|
|
67
|
+
minutes = duration if duration is not None else minutes
|
|
68
|
+
# An absent percentage means unavailable, never zero used / 100% remaining.
|
|
69
|
+
return {"label": label, "used_percent": used,
|
|
70
|
+
"remaining_percent": None if used is None else round(100 - used, 4),
|
|
71
|
+
"resets_at": reset, "window_minutes": minutes}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def codex_snapshot(value, observed_at, camel=False):
|
|
75
|
+
at = timestamp(observed_at)
|
|
76
|
+
if not isinstance(value, dict) or at is None: return None
|
|
77
|
+
windows = []
|
|
78
|
+
for key in ("primary", "secondary"):
|
|
79
|
+
w = window(value.get(key), key.title(), camel=camel)
|
|
80
|
+
if w:
|
|
81
|
+
mins = w["window_minutes"]
|
|
82
|
+
w["label"] = "5-hour limit" if mins == 300 else "Weekly limit" if mins == 10080 else (
|
|
83
|
+
f"{mins:g}-minute limit" if mins is not None else key.title() + " limit")
|
|
84
|
+
windows.append(w)
|
|
85
|
+
return {"observed_at": at, "limit_id": text(value.get("limitId" if camel else "limit_id")) or "codex",
|
|
86
|
+
"limit_name": text(value.get("limitName" if camel else "limit_name")),
|
|
87
|
+
"plan": text(value.get("planType" if camel else "plan_type")), "windows": windows}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def observe_codex(agg, kind, payload, at):
|
|
91
|
+
metadata = agg.setdefault("account_metadata", {})
|
|
92
|
+
if kind == "session_meta":
|
|
93
|
+
for key in ("creator_account_id", "creator_user_id"):
|
|
94
|
+
value = text(payload.get(key))
|
|
95
|
+
if value: metadata[key] = value
|
|
96
|
+
if kind == "event_msg" and payload.get("type") == "token_count":
|
|
97
|
+
snapshot = codex_snapshot(payload.get("rate_limits"), at)
|
|
98
|
+
if snapshot:
|
|
99
|
+
snapshots = metadata.setdefault("quota_snapshots", {})
|
|
100
|
+
key = snapshot["limit_id"]
|
|
101
|
+
previous = snapshots.get(key)
|
|
102
|
+
if previous is None or snapshot["observed_at"] >= previous["observed_at"]:
|
|
103
|
+
if key in snapshots or len(snapshots) < 16: snapshots[key] = snapshot
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def observe_claude(agg, record):
|
|
107
|
+
value = text(record.get("accountUuid"))
|
|
108
|
+
if value:
|
|
109
|
+
ids = agg.setdefault("account_metadata", {}).setdefault("recorded_account_ids", [])
|
|
110
|
+
if value not in ids and len(ids) < 16: ids.append(value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def session_metadata(agg):
|
|
114
|
+
m = agg.get("account_metadata") or {}
|
|
115
|
+
# Deliberately omit quotas: they describe an account, not a session's usage.
|
|
116
|
+
return {key: m[key] for key in ("creator_account_id", "creator_user_id", "recorded_account_ids") if key in m}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _profile(source):
|
|
120
|
+
return str(Path(os.environ.get("CODEX_HOME" if source == "codex" else "CLAUDE_CONFIG_DIR")
|
|
121
|
+
or Path.home() / (".codex" if source == "codex" else ".claude")).resolve())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _auth_signature(source):
|
|
125
|
+
# Only stat credential files. Never read/decode tokens, API keys or keychains.
|
|
126
|
+
root = Path(_profile(source))
|
|
127
|
+
paths = [root / "auth.json"] if source == "codex" else [root / ".credentials.json", root.with_suffix(".json")]
|
|
128
|
+
signatures = []
|
|
129
|
+
for path in paths:
|
|
130
|
+
try:
|
|
131
|
+
st = path.stat(); signatures.append((str(path), st.st_size, st.st_mtime_ns))
|
|
132
|
+
except OSError: signatures.append((str(path), None, None))
|
|
133
|
+
# Environment values are never included in a response or persisted.
|
|
134
|
+
names = ("OPENAI_API_KEY", "CODEX_AUTH_TOKEN") if source == "codex" else (
|
|
135
|
+
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
136
|
+
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")
|
|
137
|
+
return (tuple(signatures), tuple(hashlib.sha256(os.environ.get(name, "").encode("utf-8")).digest() for name in names))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _environment(source):
|
|
141
|
+
environment = os.environ.copy()
|
|
142
|
+
environment.pop("AGENT_TELEMETRY_CONTROL_TOKEN", None)
|
|
143
|
+
if source == "codex": environment["CODEX_HOME"] = _profile(source)
|
|
144
|
+
else:
|
|
145
|
+
environment["CLAUDE_CONFIG_DIR"] = _profile(source)
|
|
146
|
+
environment["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
|
147
|
+
return environment
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _codex_read():
|
|
151
|
+
executable = shutil.which("codex")
|
|
152
|
+
if not executable: raise ValueError("Codex CLI is unavailable on the dashboard's PATH.")
|
|
153
|
+
# This child exposes only read methods. Never start/resume a thread/model turn.
|
|
154
|
+
process = subprocess.Popen([executable, "app-server", "--listen", "stdio://", "-c", "analytics.enabled=false"],
|
|
155
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
156
|
+
text=True, encoding="utf-8", env=_environment("codex"),
|
|
157
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
158
|
+
replies = queue.Queue()
|
|
159
|
+
def reader():
|
|
160
|
+
try:
|
|
161
|
+
for line in process.stdout:
|
|
162
|
+
try: message = json.loads(line)
|
|
163
|
+
except ValueError: continue
|
|
164
|
+
if isinstance(message, dict) and type(message.get("id")) is int:
|
|
165
|
+
replies.put(message)
|
|
166
|
+
finally:
|
|
167
|
+
# The reader owns this stream. Closing it on another thread can block
|
|
168
|
+
# forever on a read lock when a CLI descendant inherits its pipe.
|
|
169
|
+
process.stdout.close()
|
|
170
|
+
replies.put(None)
|
|
171
|
+
threading.Thread(target=reader, daemon=True).start()
|
|
172
|
+
deadline = time.monotonic() + 25
|
|
173
|
+
def request(identifier, method, params):
|
|
174
|
+
process.stdin.write(json.dumps({"id": identifier, "method": method, "params": params}) + "\n")
|
|
175
|
+
process.stdin.flush()
|
|
176
|
+
while True:
|
|
177
|
+
try: message = replies.get(timeout=max(.01, deadline - time.monotonic()))
|
|
178
|
+
except queue.Empty: raise ValueError("Codex account lookup timed out; retry or update its CLI.")
|
|
179
|
+
if message is None: raise ValueError("This Codex installation does not expose the account API.")
|
|
180
|
+
if message.get("id") != identifier: continue
|
|
181
|
+
if "error" in message: raise ValueError("Codex could not read account limits. Check sign-in or update its CLI.")
|
|
182
|
+
result = message.get("result")
|
|
183
|
+
if not isinstance(result, dict): raise ValueError("Codex returned an unsupported account response.")
|
|
184
|
+
return result
|
|
185
|
+
try:
|
|
186
|
+
request(1, "initialize", {"clientInfo": {"name": "agent_telemetry", "title": "AgentTelemetry", "version": "2.0.0"}})
|
|
187
|
+
process.stdin.write('{"method":"initialized","params":{}}\n'); process.stdin.flush()
|
|
188
|
+
before = request(2, "account/read", {"refreshToken": False}).get("account")
|
|
189
|
+
limits = request(3, "account/rateLimits/read", {}) if isinstance(before, dict) and before.get("type") == "chatgpt" else {}
|
|
190
|
+
after = request(4, "account/read", {"refreshToken": False}).get("account")
|
|
191
|
+
if before != after: raise AccountChanged("Codex account changed during lookup; discard and retry.")
|
|
192
|
+
return before, limits
|
|
193
|
+
except (OSError, BrokenPipeError):
|
|
194
|
+
raise ValueError("Codex account connection failed; retry or update its CLI.") from None
|
|
195
|
+
finally:
|
|
196
|
+
process.stdin.close()
|
|
197
|
+
try: process.wait(timeout=3)
|
|
198
|
+
except subprocess.TimeoutExpired:
|
|
199
|
+
process.terminate() # only our metadata child, never the user's Codex
|
|
200
|
+
try: process.wait(timeout=3)
|
|
201
|
+
except subprocess.TimeoutExpired: pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _claude_read():
|
|
205
|
+
executable = shutil.which("claude")
|
|
206
|
+
if not executable: raise ValueError("Claude Code CLI is unavailable on the dashboard's PATH.")
|
|
207
|
+
try:
|
|
208
|
+
result = subprocess.run([executable, "auth", "status"], capture_output=True,
|
|
209
|
+
text=True, encoding="utf-8", timeout=20, env=_environment("claude"),
|
|
210
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
211
|
+
data = json.loads(result.stdout)
|
|
212
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
213
|
+
raise ValueError("Claude auth status is unavailable; check sign-in or update its CLI.") from None
|
|
214
|
+
if result.returncode not in (0, 1) or not isinstance(data, dict) or type(data.get("loggedIn")) is not bool:
|
|
215
|
+
raise ValueError("Claude returned an unsupported auth status response.")
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def refresh_account(source, confirmed=False):
|
|
220
|
+
if source not in ("codex", "claude"): raise ValueError("Unsupported account source.")
|
|
221
|
+
if confirmed is not True: raise ValueError("Explicit consent is required for a CLI account lookup that may contact the provider.")
|
|
222
|
+
with _lock:
|
|
223
|
+
signature = _auth_signature(source)
|
|
224
|
+
try:
|
|
225
|
+
if source == "codex":
|
|
226
|
+
account, limits = _codex_read()
|
|
227
|
+
account = account if isinstance(account, dict) else {}
|
|
228
|
+
identity = {"auth": text(account.get("type")) or "signed out / unavailable",
|
|
229
|
+
"email": text(account.get("email")), "plan": text(account.get("planType")),
|
|
230
|
+
"credential_source": text(account.get("credentialSource"))}
|
|
231
|
+
buckets = limits.get("rateLimitsByLimitId")
|
|
232
|
+
values = list(buckets.values())[:16] if isinstance(buckets, dict) and buckets else [limits.get("rateLimits")]
|
|
233
|
+
snapshots = [s for s in (codex_snapshot(v, time.time(), camel=True) for v in values) if s]
|
|
234
|
+
else:
|
|
235
|
+
account = _claude_read()
|
|
236
|
+
identity = {"auth": text(account.get("authMethod")) if account["loggedIn"] else "signed out",
|
|
237
|
+
"email": text(account.get("email")), "plan": text(account.get("subscriptionType")),
|
|
238
|
+
"provider": text(account.get("apiProvider")), "organization_id": text(account.get("orgId"))}
|
|
239
|
+
snapshots = []
|
|
240
|
+
if signature != _auth_signature(source):
|
|
241
|
+
raise AccountChanged("Account files changed during lookup; discard and retry.")
|
|
242
|
+
_live[source] = {"identity": identity, "snapshots": snapshots,
|
|
243
|
+
"observed_at": time.time(), "signature": signature, "error": None}
|
|
244
|
+
except AccountChanged:
|
|
245
|
+
_live.pop(source, None)
|
|
246
|
+
raise
|
|
247
|
+
except ValueError as error:
|
|
248
|
+
old = _live.get(source)
|
|
249
|
+
if old and old["signature"] == signature: old["error"] = str(error)
|
|
250
|
+
else: _live.pop(source, None)
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _present(snapshot, now, force_stale=False):
|
|
255
|
+
result = dict(snapshot)
|
|
256
|
+
age = now - snapshot["observed_at"]
|
|
257
|
+
result["stale"] = force_stale or age < 0 or age > STALE_AFTER
|
|
258
|
+
result["windows"] = [dict(w, stale=result["stale"] or w["resets_at"] is None or now >= w["resets_at"],
|
|
259
|
+
expired=w["resets_at"] is not None and now >= w["resets_at"])
|
|
260
|
+
for w in snapshot["windows"]]
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def build_accounts(aggregates, device, now=None):
|
|
265
|
+
now = time.time() if now is None else now
|
|
266
|
+
# Different profiles and metered buckets stay separate. Never sum percentages.
|
|
267
|
+
observed = {}
|
|
268
|
+
for agg in aggregates:
|
|
269
|
+
if agg.get("_device") and agg["_device"] != device: continue
|
|
270
|
+
if agg.get("source") != "codex": continue
|
|
271
|
+
# Ledger paths can come from another OS. Inspect components without
|
|
272
|
+
# interpreting a copied Windows path as one filename on macOS/Linux.
|
|
273
|
+
path = PurePosixPath(str(agg.get("path", "")).replace("\\", "/"))
|
|
274
|
+
root = next((p.parent for p in path.parents if p.name in ("sessions", "archived_sessions")), None)
|
|
275
|
+
profile = str(root) if root else "unresolved profile"
|
|
276
|
+
for key, snapshot in (agg.get("account_metadata", {}).get("quota_snapshots") or {}).items():
|
|
277
|
+
slot = (profile, key)
|
|
278
|
+
if slot not in observed or snapshot["observed_at"] > observed[slot]["observed_at"]:
|
|
279
|
+
observed[slot] = snapshot
|
|
280
|
+
entries = []
|
|
281
|
+
with _lock:
|
|
282
|
+
for source in ("codex", "claude"):
|
|
283
|
+
entry = {"source": source, "device": device, "profile": _profile(source), "identity": None,
|
|
284
|
+
"identity_stale": True, "snapshots": [], "error": None}
|
|
285
|
+
live = _live.get(source)
|
|
286
|
+
if live and live["signature"] == _auth_signature(source):
|
|
287
|
+
entry.update(identity=live["identity"], observed_at=live["observed_at"],
|
|
288
|
+
identity_stale=not 0 <= now - live["observed_at"] <= STALE_AFTER or bool(live["error"]),
|
|
289
|
+
error=live["error"])
|
|
290
|
+
entry["snapshots"] = [dict(_present(s, now, bool(live["error"])), origin="Codex account API",
|
|
291
|
+
attribution="Account reported by this CLI lookup; other running processes may use different auth.") for s in live["snapshots"]]
|
|
292
|
+
elif live:
|
|
293
|
+
entry["error"] = "Sign-in metadata changed. Refresh account details; previous account limits hidden."
|
|
294
|
+
if source == "claude":
|
|
295
|
+
try:
|
|
296
|
+
capture = json.loads((N.support_dir() / "claude-quota-snapshot.json").read_text(encoding="utf-8"))
|
|
297
|
+
captured = claude_snapshot(capture.get("rate_limits"), capture.get("observed_at"))
|
|
298
|
+
if captured: entry["snapshots"].append(dict(_present(captured, now), origin="Claude status-line capture",
|
|
299
|
+
attribution="Account identity is not included in the status-line payload; cannot match this to the displayed sign-in."))
|
|
300
|
+
except FileNotFoundError: pass
|
|
301
|
+
except (OSError, ValueError, AttributeError, TypeError):
|
|
302
|
+
entry["error"] = "Claude status-line capture is unreadable; no quota inferred."
|
|
303
|
+
entry["unavailable"] = ("Refresh account details to read this CLI's auth and plan." if not entry["identity"] else
|
|
304
|
+
"Subscription quotas are unavailable for API-key or other non-ChatGPT auth." if source == "codex" and entry["identity"]["auth"] != "chatgpt" else
|
|
305
|
+
"No quota snapshot returned." if source == "codex" else
|
|
306
|
+
"Claude auth status has no quota percentages. Configure the optional local status-line capture.")
|
|
307
|
+
entries.append(entry)
|
|
308
|
+
# Offline log observations are intentionally separate from current sign-in.
|
|
309
|
+
for (profile, _), snapshot in sorted(observed.items()):
|
|
310
|
+
entries.append({"source": "codex", "device": device, "profile": profile, "identity": None,
|
|
311
|
+
"snapshots": [dict(_present(snapshot, now), origin="Local Codex log",
|
|
312
|
+
attribution="Account-wide observation; billing account is not recorded with this snapshot. It may belong to a previous sign-in.")],
|
|
313
|
+
"unavailable": "Historical log observation; current account/auth unavailable.", "identity_stale": True})
|
|
314
|
+
return {"entries": entries, "stale_after_seconds": STALE_AFTER, "device": device}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def claude_snapshot(value, observed_at):
|
|
318
|
+
if not isinstance(value, dict) or timestamp(observed_at) is None: return None
|
|
319
|
+
windows = []
|
|
320
|
+
for key, label, mins in (("five_hour", "5-hour limit", 300), ("seven_day", "Weekly limit", 10080)):
|
|
321
|
+
raw = value.get(key)
|
|
322
|
+
if isinstance(raw, dict):
|
|
323
|
+
w = window({"used_percent": raw.get("used_percentage"), "resets_at": raw.get("resets_at")}, label, mins)
|
|
324
|
+
windows.append(w)
|
|
325
|
+
return {"observed_at": timestamp(observed_at), "limit_id": "claude", "plan": None, "windows": windows}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def capture_claude(data):
|
|
329
|
+
if not isinstance(data, dict): raise ValueError("Expected a Claude status-line JSON object.")
|
|
330
|
+
snapshot = claude_snapshot(data.get("rate_limits"), time.time())
|
|
331
|
+
# Store exactly the two documented subscriber windows, never prompts, paths,
|
|
332
|
+
# spend-limit gateway data, credentials or context-window percentages.
|
|
333
|
+
rate = {}
|
|
334
|
+
for w in (snapshot or {}).get("windows", []):
|
|
335
|
+
key = "five_hour" if w["window_minutes"] == 300 else "seven_day"
|
|
336
|
+
rate[key] = {"used_percentage": w["used_percent"], "resets_at": w["resets_at"]}
|
|
337
|
+
# An API-key / missing-windows payload replaces the old snapshot, not retains it.
|
|
338
|
+
N._write("claude-quota-snapshot.json", {"observed_at": time.time(), "rate_limits": rate})
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
cli = argparse.ArgumentParser(description="Capture only Claude's documented quota fields from status-line stdin.")
|
|
343
|
+
cli.add_argument("--capture-claude", action="store_true", required=True)
|
|
344
|
+
cli.parse_args()
|
|
345
|
+
try:
|
|
346
|
+
data = json.loads(sys.stdin.read(1024 * 1024))
|
|
347
|
+
capture_claude(data)
|
|
348
|
+
# Usable as a standalone status line; wrappers can discard our output.
|
|
349
|
+
snapshot = claude_snapshot(data.get("rate_limits"), time.time())
|
|
350
|
+
parts = [f"{w['label']}: {100 - w['used_percent']:g}% left" for w in (snapshot or {}).get("windows", [])
|
|
351
|
+
if w["used_percent"] is not None]
|
|
352
|
+
print(" · ".join(parts) or "AgentTelemetry: subscription limits unavailable")
|
|
353
|
+
except (OSError, ValueError) as error:
|
|
354
|
+
sys.stderr.write("AgentTelemetry quota capture failed: " + type(error).__name__ + "\n")
|
|
355
|
+
sys.exit(1)
|