usage-cli 0.29.32__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 (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
usage_client.py ADDED
@@ -0,0 +1,487 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import math
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from enum import StrEnum
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from i18n import _t
21
+ from setup_hook import current_hook_state
22
+ from usage_lang import detect_lang
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
27
+ LEGACY_STATUS_FILE = os.path.expanduser("~/.claude/usag-status.json")
28
+ TT_STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
29
+ CLAUDE_JSON_FILE = os.path.expanduser("~/.claude.json")
30
+ CLAUDE_PROJECTS_DIR = Path(os.path.expanduser("~/.claude/projects"))
31
+
32
+ # Stale files only affect hints; quota values still render.
33
+ STALE_SECONDS = 6 * 3600
34
+ RECENT_ACTIVITY_SECONDS = 30 * 60
35
+ RECENT_ACTIVITY_CACHE_TTL_SECONDS = 75
36
+ HOOK_BROKEN_NOT_INSTALLED = "hook_broken_not_installed"
37
+ HOOK_BROKEN_RESTART = "hook_broken_restart"
38
+
39
+
40
+ class PollState(StrEnum):
41
+ LOADING = "loading"
42
+ SUCCESS = "success"
43
+ TOKEN_ERROR = "token_error"
44
+ CONNECTION_ERROR = "connection_error"
45
+ RATE_LIMITED = "rate_limited"
46
+ FATAL = "fatal"
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class UsageSnapshot:
51
+ current_percent: int | None
52
+ current_reset_at: float
53
+ weekly_percent: int | None
54
+ weekly_reset_at: float
55
+ current_status: str
56
+ polled_at: float
57
+ is_stale: bool = False
58
+ data_source: str = "hook"
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class PollOutcome:
63
+ state: PollState
64
+ snapshot: UsageSnapshot | None = None
65
+ message: str | None = None
66
+ _mtime: float | None = None
67
+ _status_path: str | None = None
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class _RecentActivityCache:
72
+ checked_at: float
73
+ result: bool
74
+
75
+
76
+ _recent_activity_cache: _RecentActivityCache | None = None
77
+
78
+
79
+ def _pct(value: Any) -> int | None:
80
+ numeric = _as_finite_float(value)
81
+ if numeric is None:
82
+ return None
83
+ return max(0, min(100, round(numeric)))
84
+
85
+
86
+ def _reset_at(value: Any, default: float) -> float:
87
+ numeric = _as_finite_float(value)
88
+ if numeric is None:
89
+ return default
90
+ return numeric
91
+
92
+
93
+ def _as_dict(value: Any) -> dict[str, Any]:
94
+ return value if isinstance(value, dict) else {}
95
+
96
+
97
+ def _as_finite_float(value: Any) -> float | None:
98
+ if isinstance(value, bool):
99
+ return None
100
+ try:
101
+ numeric = float(value)
102
+ except (TypeError, ValueError):
103
+ return None
104
+ return numeric if math.isfinite(numeric) else None
105
+
106
+
107
+ def _iso_timestamp(value: Any) -> float | None:
108
+ if not isinstance(value, str):
109
+ return None
110
+ try:
111
+ parsed = datetime.fromisoformat(value)
112
+ except ValueError:
113
+ return None
114
+ if parsed.tzinfo is None:
115
+ return None
116
+ try:
117
+ return parsed.timestamp()
118
+ except (OSError, OverflowError, ValueError):
119
+ return None
120
+
121
+
122
+ def _read_status_file() -> tuple[dict[str, Any], str, float] | None:
123
+ """Read the first available status JSON, preferring usage-owned files."""
124
+ for path in (STATUS_FILE, LEGACY_STATUS_FILE, TT_STATUS_FILE):
125
+ try:
126
+ mtime = os.stat(path).st_mtime
127
+ except OSError:
128
+ continue
129
+ try:
130
+ with open(path, encoding="utf-8") as f:
131
+ data = json.load(f)
132
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
133
+ if os.environ.get("USAGE_DEBUG") == "1":
134
+ logger.warning("failed to read status file %s", path, exc_info=True)
135
+ continue
136
+ if isinstance(data, dict):
137
+ return data, path, mtime
138
+ if os.environ.get("USAGE_DEBUG") == "1":
139
+ logger.warning("status file %s is not a JSON object", path)
140
+ return None
141
+
142
+
143
+ def _status_file_stat() -> tuple[str, float] | None:
144
+ for path in (STATUS_FILE, LEGACY_STATUS_FILE, TT_STATUS_FILE):
145
+ try:
146
+ return path, os.stat(path).st_mtime
147
+ except OSError:
148
+ continue
149
+ return None
150
+
151
+
152
+ def _source_from_path(source_path: str) -> str:
153
+ if source_path == TT_STATUS_FILE:
154
+ return "tt-fallback"
155
+ return "hook"
156
+
157
+
158
+ def _read_claude_json_snapshot() -> UsageSnapshot | None:
159
+ """Read Claude Code's own cached quota utilization as a fallback."""
160
+ try:
161
+ os.stat(CLAUDE_JSON_FILE)
162
+ with open(CLAUDE_JSON_FILE, encoding="utf-8") as f:
163
+ data = json.load(f)
164
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
165
+ return None
166
+ if not isinstance(data, dict):
167
+ return None
168
+
169
+ cached = _as_dict(data.get("cachedUsageUtilization"))
170
+ fetched_at_ms = _as_finite_float(cached.get("fetchedAtMs"))
171
+ if fetched_at_ms is None:
172
+ return None
173
+ utilization = _as_dict(cached.get("utilization"))
174
+ five = _as_dict(utilization.get("five_hour"))
175
+ seven = _as_dict(utilization.get("seven_day"))
176
+ five_raw = five.get("utilization")
177
+ seven_raw = seven.get("utilization")
178
+ if five_raw is None and seven_raw is None:
179
+ return None
180
+
181
+ five_pct = _pct(five_raw) if five_raw is not None else None
182
+ seven_pct = _pct(seven_raw) if seven_raw is not None else None
183
+ if five_pct is None and seven_pct is None:
184
+ return None
185
+
186
+ now = time.time()
187
+ five_reset = _iso_timestamp(five.get("resets_at")) if five else None
188
+ seven_reset = _iso_timestamp(seven.get("resets_at")) if seven else None
189
+ if five and five_reset is None:
190
+ return None
191
+ if seven and seven_reset is None:
192
+ return None
193
+ five_reset = five_reset if five_reset is not None else now
194
+ seven_reset = seven_reset if seven_reset is not None else now
195
+ if five_pct is not None and five_reset < now:
196
+ five_pct = 0
197
+ if seven_pct is not None and seven_reset < now:
198
+ seven_pct = 0
199
+
200
+ polled_at = fetched_at_ms / 1000
201
+ return UsageSnapshot(
202
+ current_percent=five_pct,
203
+ current_reset_at=five_reset,
204
+ weekly_percent=seven_pct,
205
+ weekly_reset_at=seven_reset,
206
+ current_status="",
207
+ polled_at=polled_at,
208
+ is_stale=(now - polled_at) > STALE_SECONDS,
209
+ data_source="claude-json",
210
+ )
211
+
212
+
213
+ def _time_adjusted(snapshot: UsageSnapshot) -> UsageSnapshot:
214
+ """Re-derive expiry-sensitive fields of a cached snapshot at the current time."""
215
+ now = time.time()
216
+ five_pct = snapshot.current_percent
217
+ if five_pct is not None and snapshot.current_reset_at < now:
218
+ five_pct = 0
219
+ seven_pct = snapshot.weekly_percent
220
+ if seven_pct is not None and snapshot.weekly_reset_at < now:
221
+ seven_pct = 0
222
+ return UsageSnapshot(
223
+ current_percent=five_pct,
224
+ current_reset_at=snapshot.current_reset_at,
225
+ weekly_percent=seven_pct,
226
+ weekly_reset_at=snapshot.weekly_reset_at,
227
+ current_status=snapshot.current_status,
228
+ polled_at=snapshot.polled_at,
229
+ is_stale=(now - snapshot.polled_at) > STALE_SECONDS,
230
+ data_source=snapshot.data_source,
231
+ )
232
+
233
+
234
+ def _has_recent_claude_project_activity(now: float) -> bool:
235
+ global _recent_activity_cache
236
+
237
+ if (
238
+ _recent_activity_cache is not None
239
+ and now - _recent_activity_cache.checked_at < RECENT_ACTIVITY_CACHE_TTL_SECONDS
240
+ ):
241
+ return _recent_activity_cache.result
242
+
243
+ result = False
244
+ try:
245
+ for path in CLAUDE_PROJECTS_DIR.rglob("*.jsonl"):
246
+ try:
247
+ if now - path.stat().st_mtime <= RECENT_ACTIVITY_SECONDS:
248
+ result = True
249
+ break
250
+ except OSError:
251
+ continue
252
+ except OSError:
253
+ result = False
254
+ _recent_activity_cache = _RecentActivityCache(checked_at=now, result=result)
255
+ return result
256
+
257
+
258
+ def _hook_broken_message(now: float, polled_at: float) -> str | None:
259
+ if now - polled_at <= RECENT_ACTIVITY_SECONDS:
260
+ return None
261
+ if not _has_recent_claude_project_activity(now):
262
+ return None
263
+ hook_state = current_hook_state()
264
+ if hook_state in {"us-direct", "us-forwarder"}:
265
+ return HOOK_BROKEN_RESTART
266
+ return HOOK_BROKEN_NOT_INSTALLED
267
+
268
+
269
+ def _has_complete_rate_limits(data: dict[str, Any]) -> bool:
270
+ rl = data.get("rate_limits")
271
+ if not isinstance(rl, dict):
272
+ return False
273
+ five = rl.get("five_hour")
274
+ seven = rl.get("seven_day")
275
+ if not isinstance(five, dict) or not isinstance(seven, dict):
276
+ return False
277
+ return (
278
+ _as_finite_float(five.get("used_percentage")) is not None
279
+ and _as_finite_float(seven.get("used_percentage")) is not None
280
+ )
281
+
282
+
283
+ def _build_snapshot(data: dict[str, Any], *, data_source: str = "hook") -> UsageSnapshot | None:
284
+ rl = _as_dict(data.get("rate_limits"))
285
+ five = _as_dict(rl.get("five_hour"))
286
+ seven = _as_dict(rl.get("seven_day"))
287
+
288
+ five_pct_raw = five.get("used_percentage")
289
+ seven_pct_raw = seven.get("used_percentage")
290
+ if five_pct_raw is None and seven_pct_raw is None:
291
+ return None
292
+
293
+ now = time.time()
294
+ five_reset = _reset_at(five.get("resets_at"), now)
295
+ seven_reset = _reset_at(seven.get("resets_at"), now)
296
+
297
+ # Reset expired percentages to match Claude Code rate-limit semantics.
298
+ five_pct = (
299
+ 0
300
+ if five_reset and five_reset < now
301
+ else _pct(five_pct_raw)
302
+ if five_pct_raw is not None
303
+ else None
304
+ )
305
+ seven_pct = (
306
+ 0
307
+ if seven_reset and seven_reset < now
308
+ else _pct(seven_pct_raw)
309
+ if seven_pct_raw is not None
310
+ else None
311
+ )
312
+
313
+ polled_at = _as_finite_float(data.get("_received_at_ts")) or now
314
+
315
+ status = ""
316
+ if isinstance(rl.get("status"), str):
317
+ status = rl["status"]
318
+
319
+ return UsageSnapshot(
320
+ current_percent=five_pct,
321
+ current_reset_at=five_reset,
322
+ weekly_percent=seven_pct,
323
+ weekly_reset_at=seven_reset,
324
+ current_status=status,
325
+ polled_at=polled_at,
326
+ is_stale=(now - polled_at) > STALE_SECONDS,
327
+ data_source=data_source,
328
+ )
329
+
330
+
331
+ class ClaudeUsageClient:
332
+ """Read quota state from the local JSON written by the Claude Code statusLine hook."""
333
+
334
+ def __init__(self, *, interval_seconds: int = 60, mock: bool = False) -> None:
335
+ self.interval_seconds = interval_seconds
336
+ self.mock = mock
337
+ self._last_outcome: PollOutcome | None = None
338
+ self._cached_data: dict[str, Any] | None = None
339
+ self._cached_path: str | None = None
340
+ self._cached_mtime: float | None = None
341
+ self._claude_json_cached_path: str | None = None
342
+ self._claude_json_cached_mtime: float | None = None
343
+ self._claude_json_cached_snapshot: UsageSnapshot | None = None
344
+ self._claude_json_cache_valid = False
345
+
346
+ async def aclose(self) -> None:
347
+ return None
348
+
349
+ async def fetch_once(self) -> PollOutcome:
350
+ if self.mock:
351
+ return self._mock_outcome()
352
+
353
+ claude_json_snapshot = self._read_claude_json_snapshot_cached()
354
+
355
+ if (
356
+ (stat_result := _status_file_stat()) is not None
357
+ and self._cached_data is not None
358
+ and self._cached_path == stat_result[0]
359
+ and self._cached_mtime == stat_result[1]
360
+ ):
361
+ data = self._cached_data
362
+ source_path, mtime = stat_result
363
+ else:
364
+ result = _read_status_file()
365
+ if result is None:
366
+ self._last_outcome = None
367
+ self._cached_data = None
368
+ self._cached_path = None
369
+ self._cached_mtime = None
370
+ if claude_json_snapshot is not None:
371
+ return self._success_outcome(claude_json_snapshot)
372
+ message_key = "usage_status_missing"
373
+ if current_hook_state() in {
374
+ "us-direct",
375
+ "us-forwarder",
376
+ } and _has_recent_claude_project_activity(time.time()):
377
+ message_key = "usage_status_missing_active"
378
+ return PollOutcome(
379
+ state=PollState.TOKEN_ERROR,
380
+ message=_t(detect_lang(), message_key),
381
+ )
382
+
383
+ data, source_path, mtime = result
384
+ self._cached_data = data
385
+ self._cached_path = source_path
386
+ self._cached_mtime = mtime
387
+
388
+ # ``.claude.json`` is Claude Code's cache, not a competing live source.
389
+ # In particular its fetchedAtMs can be newer than the hook timestamp while
390
+ # still describing a different/expired session. A complete statusLine
391
+ # payload must therefore always win; use the cache only when the hook has
392
+ # not provided both quota windows yet.
393
+ if claude_json_snapshot is not None and not _has_complete_rate_limits(data):
394
+ return self._success_outcome(claude_json_snapshot)
395
+
396
+ if not _has_complete_rate_limits(data):
397
+ outcome = PollOutcome(
398
+ state=PollState.LOADING,
399
+ message="awaiting_rate_limits",
400
+ _mtime=mtime,
401
+ _status_path=source_path,
402
+ )
403
+ self._last_outcome = outcome
404
+ return outcome
405
+
406
+ snapshot = _build_snapshot(data, data_source=_source_from_path(source_path))
407
+ if snapshot is None:
408
+ outcome = PollOutcome(
409
+ state=PollState.LOADING,
410
+ message=_t(detect_lang(), "usage_status_no_quota"),
411
+ _mtime=mtime,
412
+ _status_path=source_path,
413
+ )
414
+ self._last_outcome = outcome
415
+ return outcome
416
+
417
+ return self._success_outcome(snapshot, mtime=mtime, source_path=source_path)
418
+
419
+ def _read_claude_json_snapshot_cached(self) -> UsageSnapshot | None:
420
+ try:
421
+ mtime = os.stat(CLAUDE_JSON_FILE).st_mtime
422
+ except OSError:
423
+ self._claude_json_cache_valid = False
424
+ self._claude_json_cached_path = None
425
+ self._claude_json_cached_mtime = None
426
+ self._claude_json_cached_snapshot = None
427
+ return None
428
+ if (
429
+ self._claude_json_cache_valid
430
+ and self._claude_json_cached_path == CLAUDE_JSON_FILE
431
+ and self._claude_json_cached_mtime == mtime
432
+ ):
433
+ # The file may sit unchanged across a quota reset, so the expiry-derived
434
+ # fields must be recomputed on every hit — only the parse is cached.
435
+ if self._claude_json_cached_snapshot is None:
436
+ return None
437
+ return _time_adjusted(self._claude_json_cached_snapshot)
438
+ snapshot = _read_claude_json_snapshot()
439
+ self._claude_json_cache_valid = True
440
+ self._claude_json_cached_path = CLAUDE_JSON_FILE
441
+ self._claude_json_cached_mtime = mtime
442
+ self._claude_json_cached_snapshot = snapshot
443
+ return snapshot
444
+
445
+ def _success_outcome(
446
+ self,
447
+ snapshot: UsageSnapshot,
448
+ *,
449
+ mtime: float | None = None,
450
+ source_path: str | None = None,
451
+ ) -> PollOutcome:
452
+ now = time.time()
453
+ message = _hook_broken_message(now, snapshot.polled_at)
454
+ if snapshot.is_stale:
455
+ source_tag = {
456
+ "tt-fallback": "tt-status",
457
+ "claude-json": "claude.json",
458
+ }.get(snapshot.data_source, "usage")
459
+ mins = int((now - snapshot.polled_at) / 60)
460
+ message = message or f"⚠ {source_tag} stale {mins}m"
461
+
462
+ outcome = PollOutcome(
463
+ state=PollState.SUCCESS,
464
+ snapshot=snapshot,
465
+ message=message,
466
+ _mtime=mtime,
467
+ _status_path=source_path,
468
+ )
469
+ self._last_outcome = outcome
470
+ return outcome
471
+
472
+ def _mock_outcome(self) -> PollOutcome:
473
+ now = time.time()
474
+ return PollOutcome(
475
+ state=PollState.SUCCESS,
476
+ snapshot=UsageSnapshot(
477
+ current_percent=50,
478
+ current_reset_at=now + 82 * 60,
479
+ weekly_percent=11,
480
+ weekly_reset_at=now + ((6 * 24) + 8) * 3600,
481
+ current_status="ok",
482
+ polled_at=now,
483
+ is_stale=False,
484
+ data_source="hook",
485
+ ),
486
+ message=None,
487
+ )
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
4
+ #
5
+ # Part of "usage". Free software licensed under the GNU Affero General Public
6
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import tempfile
15
+ import threading
16
+ from datetime import UTC, datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from analyzer import diagnoser, reporter
21
+ from time_utils import parse_optional_iso8601_utc
22
+
23
+ SNAPSHOT_PATH = Path(os.path.expanduser("~/.claude/usage-diagnosis.json"))
24
+ _LOOKBACK_DAYS = 7
25
+ _STALE_AFTER = timedelta(hours=24)
26
+ _lock = threading.Lock()
27
+ _refresh_in_flight = False
28
+
29
+
30
+ def maybe_schedule_refresh() -> None:
31
+ if not _needs_refresh(_read_snapshot(), now=datetime.now(UTC)):
32
+ return
33
+
34
+ global _refresh_in_flight
35
+ with _lock:
36
+ if _refresh_in_flight:
37
+ return
38
+ _refresh_in_flight = True
39
+
40
+ thread = threading.Thread(target=_refresh_in_background, daemon=True)
41
+ thread.start()
42
+
43
+
44
+ def _refresh_in_background() -> None:
45
+ global _refresh_in_flight
46
+ try:
47
+ refresh_snapshot()
48
+ finally:
49
+ with _lock:
50
+ _refresh_in_flight = False
51
+
52
+
53
+ def refresh_snapshot(now: datetime | None = None) -> bool:
54
+ current_time = now or datetime.now(UTC)
55
+ existing = _read_snapshot()
56
+ if not _needs_refresh(existing, now=current_time):
57
+ return False
58
+
59
+ date_to = current_time.date()
60
+ date_from = date_to - timedelta(days=_LOOKBACK_DAYS - 1)
61
+ tool_calls, sessions = diagnoser._load_records(date_from, date_to)
62
+ diagnosis = diagnoser.analyze_loaded_records(
63
+ date_from=date_from,
64
+ date_to=date_to,
65
+ total_cost_usd=0.0,
66
+ tool_calls=tool_calls,
67
+ entries=None,
68
+ sessions=sessions,
69
+ )
70
+ total_corpus_tokens = sum(session.total_tokens for session in sessions)
71
+ payload = reporter.serialize_diagnosis(
72
+ diagnosis,
73
+ total_corpus_tokens=total_corpus_tokens,
74
+ )
75
+ payload["generated_at"] = _format_timestamp(current_time)
76
+ payload["findings_fingerprint"] = _findings_fingerprint(payload.get("findings"))
77
+ _atomic_write_json(SNAPSHOT_PATH, payload)
78
+ return True
79
+
80
+
81
+ def _needs_refresh(snapshot: dict[str, Any] | None, *, now: datetime) -> bool:
82
+ if snapshot is None:
83
+ return True
84
+ generated_at = _parse_timestamp(snapshot.get("generated_at"))
85
+ if generated_at is None:
86
+ return True
87
+ return now - generated_at >= _STALE_AFTER
88
+
89
+
90
+ def _findings_fingerprint(findings: object) -> str:
91
+ if not isinstance(findings, list):
92
+ return ""
93
+
94
+ parts: list[str] = []
95
+ for finding in findings:
96
+ if not isinstance(finding, dict):
97
+ continue
98
+ kind = finding.get("kind")
99
+ items = finding.get("items")
100
+ if not isinstance(kind, str):
101
+ continue
102
+ item_hash = ""
103
+ if isinstance(items, list) and items:
104
+ first_item = items[0]
105
+ encoded = json.dumps(
106
+ first_item, ensure_ascii=False, sort_keys=True, separators=(",", ":")
107
+ )
108
+ item_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:12]
109
+ parts.append(f"{kind}:{item_hash}")
110
+ parts.sort()
111
+ return "|".join(parts)
112
+
113
+
114
+ def _read_snapshot() -> dict[str, Any] | None:
115
+ try:
116
+ data = json.loads(SNAPSHOT_PATH.read_text(encoding="utf-8"))
117
+ except (OSError, json.JSONDecodeError, ValueError):
118
+ return None
119
+ return data if isinstance(data, dict) else None
120
+
121
+
122
+ def _parse_timestamp(value: object) -> datetime | None:
123
+ return parse_optional_iso8601_utc(value)
124
+
125
+
126
+ def _format_timestamp(value: datetime) -> str:
127
+ return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
128
+
129
+
130
+ def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ tmp_path: str | None = None
133
+ try:
134
+ fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
135
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
136
+ json.dump(payload, file, ensure_ascii=False, indent=2)
137
+ file.write("\n")
138
+ os.replace(tmp_path, path)
139
+ tmp_path = None
140
+ finally:
141
+ if tmp_path and os.path.exists(tmp_path):
142
+ with contextlib.suppress(OSError):
143
+ os.unlink(tmp_path)