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.
Files changed (36) hide show
  1. agent_telemetry/__init__.py +3 -0
  2. agent_telemetry/__main__.py +7 -0
  3. agent_telemetry/accounts.py +355 -0
  4. agent_telemetry/chart.umd.min.js +20 -0
  5. agent_telemetry/cli.py +47 -0
  6. agent_telemetry/dashboard.py +2413 -0
  7. agent_telemetry/index.html +465 -0
  8. agent_telemetry/manifest.json +22 -0
  9. agent_telemetry/native.py +135 -0
  10. agent_telemetry/parser.py +4202 -0
  11. agent_telemetry/session_names.py +270 -0
  12. agent_telemetry/static/app.css +650 -0
  13. agent_telemetry/static/apple-touch-icon.png +0 -0
  14. agent_telemetry/static/charts.js +330 -0
  15. agent_telemetry/static/core.js +522 -0
  16. agent_telemetry/static/icon-192.png +0 -0
  17. agent_telemetry/static/icon-512.png +0 -0
  18. agent_telemetry/static/tool-icons/LICENSE.hermes.txt +21 -0
  19. agent_telemetry/static/tool-icons/LICENSE.openclaw.txt +21 -0
  20. agent_telemetry/static/tool-icons/LICENSE.simple-icons.txt +30 -0
  21. agent_telemetry/static/tool-icons/README.md +21 -0
  22. agent_telemetry/static/tool-icons/claude.svg +1 -0
  23. agent_telemetry/static/tool-icons/copilot.svg +1 -0
  24. agent_telemetry/static/tool-icons/cursor.svg +1 -0
  25. agent_telemetry/static/tool-icons/hermes.svg +7 -0
  26. agent_telemetry/static/tool-icons/openai.svg +1 -0
  27. agent_telemetry/static/tool-icons/openclaw.svg +18 -0
  28. agent_telemetry/static/tool-icons/opencode.svg +1 -0
  29. agent_telemetry/static/views.js +2749 -0
  30. agent_telemetry/sw.js +70 -0
  31. agenttelemetry_app-2.0.0.dist-info/METADATA +495 -0
  32. agenttelemetry_app-2.0.0.dist-info/RECORD +36 -0
  33. agenttelemetry_app-2.0.0.dist-info/WHEEL +5 -0
  34. agenttelemetry_app-2.0.0.dist-info/entry_points.txt +4 -0
  35. agenttelemetry_app-2.0.0.dist-info/licenses/LICENSE +21 -0
  36. agenttelemetry_app-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """AgentTelemetry's local dashboard package."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,7 @@
1
+ """Run the installed dashboard with ``python -m agent_telemetry``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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)