usage-cli 0.34.16__tar.gz → 0.34.17__tar.gz

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 (298) hide show
  1. {usage_cli-0.34.16/usage_cli.egg-info → usage_cli-0.34.17}/PKG-INFO +3 -3
  2. {usage_cli-0.34.16 → usage_cli-0.34.17}/README.md +2 -2
  3. {usage_cli-0.34.16 → usage_cli-0.34.17}/i18n.json +5 -5
  4. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/session_hooks.py +1 -1
  5. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/agy_quota_probe.py +201 -19
  6. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/agy.py +10 -3
  7. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/refresh.py +3 -0
  8. {usage_cli-0.34.16 → usage_cli-0.34.17}/pyproject.toml +1 -1
  9. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/agy_window_keeper.py +51 -12
  10. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/status_payload.py +11 -1
  11. usage_cli-0.34.17/tests/test_agy_background_auth.py +536 -0
  12. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_agy_quota_probe.py +165 -1
  13. usage_cli-0.34.17/tests/test_quota_aware.py +1772 -0
  14. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_service_status.py +52 -0
  15. usage_cli-0.34.17/tests/test_status_payload.py +64 -0
  16. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_statusline_agy.py +13 -0
  17. {usage_cli-0.34.16 → usage_cli-0.34.17/usage_cli.egg-info}/PKG-INFO +3 -3
  18. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_cli.egg-info/SOURCES.txt +2 -0
  19. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/service_status.py +34 -0
  20. usage_cli-0.34.17/usage_hooks/usage_quota_aware.py +590 -0
  21. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_statusline_agy.py +10 -1
  22. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/app.py +2 -0
  23. usage_cli-0.34.16/tests/test_quota_aware.py +0 -473
  24. usage_cli-0.34.16/usage_hooks/usage_quota_aware.py +0 -217
  25. {usage_cli-0.34.16 → usage_cli-0.34.17}/LICENSE +0 -0
  26. {usage_cli-0.34.16 → usage_cli-0.34.17}/THIRD_PARTY_NOTICES.md +0 -0
  27. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/__init__.py +0 -0
  28. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/agy.py +0 -0
  29. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/claude.py +0 -0
  30. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/codex.py +0 -0
  31. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/grok.py +0 -0
  32. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/muse.py +0 -0
  33. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/rate_limits.py +0 -0
  34. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/registry.py +0 -0
  35. {usage_cli-0.34.16 → usage_cli-0.34.17}/adapters/types.py +0 -0
  36. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/__init__.py +0 -0
  37. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/aggregator.py +0 -0
  38. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/blocks.py +0 -0
  39. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/diagnoser.py +0 -0
  40. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/diagnosis_snapshot.py +0 -0
  41. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/dispatch_ledger.py +0 -0
  42. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/history_persist.py +0 -0
  43. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/insights.py +0 -0
  44. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/ledger_backfill.py +0 -0
  45. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/ledger_legacy_backfill.py +0 -0
  46. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/persona_loader.py +0 -0
  47. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/reporter.py +0 -0
  48. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/subscription.py +0 -0
  49. {usage_cli-0.34.16 → usage_cli-0.34.17}/analyzer/usage_snapshot.py +0 -0
  50. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/__init__.py +0 -0
  51. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/claude_pane.py +0 -0
  52. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/codex_hook_trust.py +0 -0
  53. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/login_item.py +0 -0
  54. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/setup_hook.py +0 -0
  55. {usage_cli-0.34.16 → usage_cli-0.34.17}/installer/statusline_settings.py +0 -0
  56. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/__init__.py +0 -0
  57. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/_shared_cache_file.py +0 -0
  58. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/agy_disk_cache.py +0 -0
  59. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/agy_loader.py +0 -0
  60. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/cache_quarantine.py +0 -0
  61. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/chromium_cache.py +0 -0
  62. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/claude_desktop.py +0 -0
  63. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/claude_paths.py +0 -0
  64. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/claude_usage.py +0 -0
  65. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/codex_disk_cache.py +0 -0
  66. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/codex_events.py +0 -0
  67. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/codex_fork_replay.py +0 -0
  68. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/codex_loader.py +0 -0
  69. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/codex_paths.py +0 -0
  70. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/disk_cache_common.py +0 -0
  71. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/disk_cache_lifecycle.py +0 -0
  72. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/grok_loader.py +0 -0
  73. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/grok_quota_probe.py +0 -0
  74. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/history_disk_cache.py +0 -0
  75. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/history_loader.py +0 -0
  76. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/jsonl_limits.py +0 -0
  77. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/jsonl_utils.py +0 -0
  78. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/muse_loader.py +0 -0
  79. {usage_cli-0.34.16 → usage_cli-0.34.17}/loaders/muse_quota_probe.py +0 -0
  80. {usage_cli-0.34.16 → usage_cli-0.34.17}/main.py +0 -0
  81. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/__init__.py +0 -0
  82. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/actions.py +0 -0
  83. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/app.py +0 -0
  84. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/chrome.py +0 -0
  85. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/fsevents_watch.py +0 -0
  86. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/grok.py +0 -0
  87. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/manual_refresh.py +0 -0
  88. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/menu.py +0 -0
  89. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/muse.py +0 -0
  90. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/new_badge.py +0 -0
  91. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/notify.py +0 -0
  92. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/popover.py +0 -0
  93. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/prefs.py +0 -0
  94. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/quota_countdown.py +0 -0
  95. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/state.py +0 -0
  96. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/title.py +0 -0
  97. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/update.py +0 -0
  98. {usage_cli-0.34.16 → usage_cli-0.34.17}/menubar/whats_new.py +0 -0
  99. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/__init__.py +0 -0
  100. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/base.py +0 -0
  101. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/catalog.py +0 -0
  102. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/dynamic_height.py +0 -0
  103. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/panel_scale.py +0 -0
  104. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/panel_window.py +0 -0
  105. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/panel_window_state.py +0 -0
  106. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/payload.py +0 -0
  107. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/web_panel.py +0 -0
  108. {usage_cli-0.34.16 → usage_cli-0.34.17}/panels/window_drag.py +0 -0
  109. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/__init__.py +0 -0
  110. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/burn_rate.py +0 -0
  111. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/codex_window_keeper.py +0 -0
  112. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/keeper_outcome.py +0 -0
  113. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/notifications.py +0 -0
  114. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/quota_snapshot.py +0 -0
  115. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/usage_rate.py +0 -0
  116. {usage_cli-0.34.16 → usage_cli-0.34.17}/quota/window_keeper.py +0 -0
  117. {usage_cli-0.34.16 → usage_cli-0.34.17}/setup.cfg +0 -0
  118. {usage_cli-0.34.16 → usage_cli-0.34.17}/setup_app.py +0 -0
  119. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_adapters_claude.py +0 -0
  120. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_adapters_codex.py +0 -0
  121. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_adapters_rate_limits.py +0 -0
  122. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_aggregator.py +0 -0
  123. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_agy_adapter.py +0 -0
  124. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_agy_disk_cache.py +0 -0
  125. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_agy_loader.py +0 -0
  126. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_agy_window_keeper.py +0 -0
  127. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_analyzer_blocks.py +0 -0
  128. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_analyzer_pipeline.py +0 -0
  129. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_app_version.py +0 -0
  130. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_burn_rate.py +0 -0
  131. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_cache_quarantine.py +0 -0
  132. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_check_release_version.py +0 -0
  133. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_check_upstream.py +0 -0
  134. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_chromium_cache.py +0 -0
  135. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_claude_desktop.py +0 -0
  136. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_claude_expired_windows.py +0 -0
  137. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_claude_pane.py +0 -0
  138. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_claude_paths.py +0 -0
  139. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_cli_lookup.py +0 -0
  140. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_codex_consistency.py +0 -0
  141. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_codex_hook_trust.py +0 -0
  142. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_codex_loader.py +0 -0
  143. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_codex_window_keeper.py +0 -0
  144. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_compact_cards.py +0 -0
  145. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_desktop_fresh_cache.py +0 -0
  146. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_desktop_quota_display.py +0 -0
  147. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_desktop_quota_review.py +0 -0
  148. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_diagnoser.py +0 -0
  149. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_disk_cache_shards.py +0 -0
  150. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_dispatch_ledger.py +0 -0
  151. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_doctor.py +0 -0
  152. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_dynamic_height.py +0 -0
  153. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_fsevents_watch.py +0 -0
  154. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_grok_loader.py +0 -0
  155. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_grok_quota_probe.py +0 -0
  156. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_history_loader.py +0 -0
  157. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_history_persist.py +0 -0
  158. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_hook_scripts_are_standalone.py +0 -0
  159. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_html_report_snapshot.py +0 -0
  160. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_i18n_format_fallback.py +0 -0
  161. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_i18n_key_parity.py +0 -0
  162. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_i18n_packaged_path.py +0 -0
  163. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_insights.py +0 -0
  164. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_jsonl_utils.py +0 -0
  165. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_keeper_menu.py +0 -0
  166. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_keeper_outcome.py +0 -0
  167. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_ledger_backfill.py +0 -0
  168. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_ledger_legacy_backfill.py +0 -0
  169. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_login_item.py +0 -0
  170. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_main.py +0 -0
  171. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_manual_refresh.py +0 -0
  172. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar.py +0 -0
  173. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_agy.py +0 -0
  174. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_grok.py +0 -0
  175. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_muse.py +0 -0
  176. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_notify.py +0 -0
  177. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_permission.py +0 -0
  178. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_prefs.py +0 -0
  179. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_state.py +0 -0
  180. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_menubar_update.py +0 -0
  181. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_muse_coverage.py +0 -0
  182. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_muse_loader.py +0 -0
  183. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_muse_quota_probe.py +0 -0
  184. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_muse_review_regressions.py +0 -0
  185. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_network.py +0 -0
  186. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_new_badge.py +0 -0
  187. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_packaged_resources.py +0 -0
  188. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_packaging.py +0 -0
  189. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_panel_footer_layout.py +0 -0
  190. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_panel_scale.py +0 -0
  191. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_panel_window_state.py +0 -0
  192. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_panels.py +0 -0
  193. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_panels_lazy_import.py +0 -0
  194. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_persona_loader.py +0 -0
  195. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_pricing.py +0 -0
  196. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_project_resolver.py +0 -0
  197. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_quota_label.py +0 -0
  198. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_quota_threshold_menu.py +0 -0
  199. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_chart_redesign.py +0 -0
  200. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_cold_read.py +0 -0
  201. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_daily_chart.py +0 -0
  202. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_filter_js.py +0 -0
  203. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_headings.py +0 -0
  204. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_report_share_mask.py +0 -0
  205. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_reporter_build_data.py +0 -0
  206. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_session_resume.py +0 -0
  207. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_setup_app.py +0 -0
  208. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_setup_hook.py +0 -0
  209. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_setup_hook_coexistence.py +0 -0
  210. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_setup_hook_resume.py +0 -0
  211. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_setup_hook_terse.py +0 -0
  212. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_subprocess_utils.py +0 -0
  213. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_subscription.py +0 -0
  214. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_taskbar_menu.py +0 -0
  215. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_taskbar_obstacles.py +0 -0
  216. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_time_utils.py +0 -0
  217. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_tui.py +0 -0
  218. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_tui_sprite.py +0 -0
  219. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_ui_tables.py +0 -0
  220. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_update_checker.py +0 -0
  221. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_update_gate.py +0 -0
  222. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_update_release_notes.py +0 -0
  223. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_cli.py +0 -0
  224. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_client.py +0 -0
  225. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_diagnosis_snapshot.py +0 -0
  226. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_dir_sweeper.py +0 -0
  227. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_lang.py +0 -0
  228. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_logging.py +0 -0
  229. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_notifications.py +0 -0
  230. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_rate.py +0 -0
  231. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_snapshot.py +0 -0
  232. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_snapshot_replay.py +0 -0
  233. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_statusline.py +0 -0
  234. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_statusline_forwarder.py +0 -0
  235. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_statusline_grok.py +0 -0
  236. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_terse_mode.py +0 -0
  237. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_usage_terse_reminder.py +0 -0
  238. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_web_panel_payload.py +0 -0
  239. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_whats_new.py +0 -0
  240. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_wheel_packaging.py +0 -0
  241. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_win_login_item.py +0 -0
  242. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_window_keeper.py +0 -0
  243. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_windows_manifest.py +0 -0
  244. {usage_cli-0.34.16 → usage_cli-0.34.17}/tests/test_wintray.py +0 -0
  245. {usage_cli-0.34.16 → usage_cli-0.34.17}/tui/__init__.py +0 -0
  246. {usage_cli-0.34.16 → usage_cli-0.34.17}/tui/app.py +0 -0
  247. {usage_cli-0.34.16 → usage_cli-0.34.17}/tui/sprite.py +0 -0
  248. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/__init__.py +0 -0
  249. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/html_report.py +0 -0
  250. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_charts.py +0 -0
  251. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_daily_chart.py +0 -0
  252. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_filter.py +0 -0
  253. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_ledger.py +0 -0
  254. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_scripts.py +0 -0
  255. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/report_styles.py +0 -0
  256. {usage_cli-0.34.16 → usage_cli-0.34.17}/ui/tables.py +0 -0
  257. {usage_cli-0.34.16 → usage_cli-0.34.17}/updates/__init__.py +0 -0
  258. {usage_cli-0.34.16 → usage_cli-0.34.17}/updates/checker.py +0 -0
  259. {usage_cli-0.34.16 → usage_cli-0.34.17}/updates/gate.py +0 -0
  260. {usage_cli-0.34.16 → usage_cli-0.34.17}/updates/release_notes.py +0 -0
  261. {usage_cli-0.34.16 → usage_cli-0.34.17}/updates/whats_new.py +0 -0
  262. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_app/__init__.py +0 -0
  263. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_app/cli.py +0 -0
  264. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_app/doctor.py +0 -0
  265. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_cli.egg-info/dependency_links.txt +0 -0
  266. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_cli.egg-info/entry_points.txt +0 -0
  267. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_cli.egg-info/requires.txt +0 -0
  268. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_cli.egg-info/top_level.txt +0 -0
  269. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/__init__.py +0 -0
  270. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/app_version.py +0 -0
  271. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/cli_lookup.py +0 -0
  272. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/i18n.py +0 -0
  273. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/network.py +0 -0
  274. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/prefs.py +0 -0
  275. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/pricing.py +0 -0
  276. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/project_resolver.py +0 -0
  277. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/subprocess_utils.py +0 -0
  278. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/time_utils.py +0 -0
  279. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/usage_dir_sweeper.py +0 -0
  280. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/usage_lang.py +0 -0
  281. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_common/usage_logging.py +0 -0
  282. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/__init__.py +0 -0
  283. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_session_resume.py +0 -0
  284. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_statusline.py +0 -0
  285. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_statusline_forwarder.py +0 -0
  286. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_statusline_grok.py +0 -0
  287. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_terse_mode.py +0 -0
  288. {usage_cli-0.34.16 → usage_cli-0.34.17}/usage_hooks/usage_terse_reminder.py +0 -0
  289. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/__init__.py +0 -0
  290. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/login_item.py +0 -0
  291. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/menu.py +0 -0
  292. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/prefs.py +0 -0
  293. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/quota_label.py +0 -0
  294. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/taskbar_menu.py +0 -0
  295. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/taskbar_obstacles.py +0 -0
  296. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/taskbar_overlay.py +0 -0
  297. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/watch.py +0 -0
  298. {usage_cli-0.34.16 → usage_cli-0.34.17}/wintray/whats_new.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usage-cli
3
- Version: 0.34.16
3
+ Version: 0.34.17
4
4
  Summary: usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)
5
5
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.13
@@ -123,7 +123,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
123
123
  | **Token Saver** | Asks Claude Code and Codex for shorter, plainer replies while keeping code and error messages byte-exact. In an A/B test on real sessions, late replies stayed **~40% shorter** instead of drifting 84% longer. | Off · no extra model calls |
124
124
  | **Side pane** | Quotas, other conversations, and background jobs next to your work. [See the side pane](#claude-code-side-pane). | Off · no extra model calls |
125
125
  | **Beginner Mode** | Explains up to three technical terms from each answer in one plain line, then brings them back for review. | Off · a little Claude quota |
126
- | **Quota-Aware Mode** | When a quota runs low, Claude tells you before a big task that would use it, and lets you do a smaller part or wait for the reset. | Off · no extra model calls |
126
+ | **Quota-Aware Mode** | When a quota runs low, Claude tells you before a big task that would use it, and lets you do a smaller part or wait for the reset. Claude also gets a short quota overview at the start of each conversation. | Off · no extra model calls |
127
127
  | **Auto-start 5-hour session** | Right after a 5-hour quota resets, sends each tool one tiny message so the next window starts counting right away. | Off · negligible quota |
128
128
  | **Token-waste health check** | A daily scan of your logs for repeated file reads and noisy output. Say "show me" and the AI walks you through fixes. | Comes with Progress Concierge · no extra model calls |
129
129
 
@@ -146,7 +146,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
146
146
  - **Linux:** CI verifies `usage setup` on Ubuntu.
147
147
  - **Progress Concierge:** When you `/resume` a conversation that sat long enough for its cache to expire, it warns how many tokens the next message will re-send and suggests `/compact` first.
148
148
  - **Beginner Mode (macOS and Windows):** Terms appear above the prompt in your UI language. Press 9 to mark them understood; skipped terms come back after 1, 3, then 7 days. Understood terms return as a one-question multiple-choice quiz 7, 21, then 60 days later, at most one a day, and a wrong answer puts the term back in the hints. `/terms` opens your term history. Picking terms asks Claude Haiku through your Claude Code, and pauses while your 5-hour quota is at 90% or more.
149
- - **Quota-Aware Mode (macOS and Windows):** Claude Code gets one line with what is left and when it resets when a 5-hour quota passes 80%, 90%, or 95%, or a weekly quota passes 95%. It covers Claude Code, Codex, Antigravity, and Muse, says each level once per conversation, and treats each quota and model group separately, so a used-up one only matters for work that runs on it.
149
+ - **Quota-Aware Mode (macOS and Windows):** Claude Code gets one line with what is left and when it resets when a 5-hour quota passes 80%, 90%, or 95%, or a weekly quota passes 95%. It covers Claude Code, Codex, Antigravity, and Muse, says each level once per conversation, tells Claude once when a quota it warned about resets, and treats each quota and model group separately, so a used-up one only matters for work that runs on it. Official Claude Code / Claude API or Codex API incidents and recovery also notify Claude, once per status change in each conversation. Claude also gets a short quota overview at the start of each conversation. After `/login` changes accounts, Claude hears once that earlier Claude Code quota notices no longer apply.
150
150
  - **Auto-start 5-hour session:** Claude gets Haiku, Antigravity gets Gemini 3.8 Flash Low, and Codex gets its cheapest model. The quota used is negligible. The Claude and Codex pings skip your own hooks and stay out of `claude --resume` and `codex resume`.
151
151
  - **Panel:** It stays put when another app takes focus; a second click on the menu bar icon, or Escape, closes it. Card order is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
152
152
  - **HTML reports:** A "What you worked on" section lists the names Claude Code gave your recent conversations, and masking covers those titles too.
@@ -101,7 +101,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
101
101
  | **Token Saver** | Asks Claude Code and Codex for shorter, plainer replies while keeping code and error messages byte-exact. In an A/B test on real sessions, late replies stayed **~40% shorter** instead of drifting 84% longer. | Off · no extra model calls |
102
102
  | **Side pane** | Quotas, other conversations, and background jobs next to your work. [See the side pane](#claude-code-side-pane). | Off · no extra model calls |
103
103
  | **Beginner Mode** | Explains up to three technical terms from each answer in one plain line, then brings them back for review. | Off · a little Claude quota |
104
- | **Quota-Aware Mode** | When a quota runs low, Claude tells you before a big task that would use it, and lets you do a smaller part or wait for the reset. | Off · no extra model calls |
104
+ | **Quota-Aware Mode** | When a quota runs low, Claude tells you before a big task that would use it, and lets you do a smaller part or wait for the reset. Claude also gets a short quota overview at the start of each conversation. | Off · no extra model calls |
105
105
  | **Auto-start 5-hour session** | Right after a 5-hour quota resets, sends each tool one tiny message so the next window starts counting right away. | Off · negligible quota |
106
106
  | **Token-waste health check** | A daily scan of your logs for repeated file reads and noisy output. Say "show me" and the AI walks you through fixes. | Comes with Progress Concierge · no extra model calls |
107
107
 
@@ -124,7 +124,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
124
124
  - **Linux:** CI verifies `usage setup` on Ubuntu.
125
125
  - **Progress Concierge:** When you `/resume` a conversation that sat long enough for its cache to expire, it warns how many tokens the next message will re-send and suggests `/compact` first.
126
126
  - **Beginner Mode (macOS and Windows):** Terms appear above the prompt in your UI language. Press 9 to mark them understood; skipped terms come back after 1, 3, then 7 days. Understood terms return as a one-question multiple-choice quiz 7, 21, then 60 days later, at most one a day, and a wrong answer puts the term back in the hints. `/terms` opens your term history. Picking terms asks Claude Haiku through your Claude Code, and pauses while your 5-hour quota is at 90% or more.
127
- - **Quota-Aware Mode (macOS and Windows):** Claude Code gets one line with what is left and when it resets when a 5-hour quota passes 80%, 90%, or 95%, or a weekly quota passes 95%. It covers Claude Code, Codex, Antigravity, and Muse, says each level once per conversation, and treats each quota and model group separately, so a used-up one only matters for work that runs on it.
127
+ - **Quota-Aware Mode (macOS and Windows):** Claude Code gets one line with what is left and when it resets when a 5-hour quota passes 80%, 90%, or 95%, or a weekly quota passes 95%. It covers Claude Code, Codex, Antigravity, and Muse, says each level once per conversation, tells Claude once when a quota it warned about resets, and treats each quota and model group separately, so a used-up one only matters for work that runs on it. Official Claude Code / Claude API or Codex API incidents and recovery also notify Claude, once per status change in each conversation. Claude also gets a short quota overview at the start of each conversation. After `/login` changes accounts, Claude hears once that earlier Claude Code quota notices no longer apply.
128
128
  - **Auto-start 5-hour session:** Claude gets Haiku, Antigravity gets Gemini 3.8 Flash Low, and Codex gets its cheapest model. The quota used is negligible. The Claude and Codex pings skip your own hooks and stay out of `claude --resume` and `codex resume`.
129
129
  - **Panel:** It stays put when another app takes focus; a second click on the menu bar icon, or Escape, closes it. Card order is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
130
130
  - **HTML reports:** A "What you worked on" section lists the names Claude Code gave your recent conversations, and masking covers those titles too.
@@ -556,7 +556,7 @@
556
556
  "claude_beginner_disabled_msg": "已關閉新手模式。重開 Claude Code 後生效。",
557
557
  "claude_beginner_action_failed": "無法切換新手模式",
558
558
  "quota_aware_menu": "額度感知模式",
559
- "quota_aware_tooltip": "額度快用完時,Claude 開大任務前會先告訴你剩多少、幾點重置,讓你選先做小的還是等重置。",
559
+ "quota_aware_tooltip": "額度快用完時,Claude 開大任務前會先告訴你剩多少、幾點重置,讓你選先做小的還是等重置。Claude Code 或 Codex 官方服務出狀況時,也會告訴 Claude。每個對話開始時,也會給 Claude 一份各家額度的簡短總覽。",
560
560
  "quota_aware_enabled_msg": "已開啟額度感知模式。請重啟 Claude Code 套用。",
561
561
  "quota_aware_disabled_msg": "已關閉額度感知模式。請重啟 Claude Code 套用。",
562
562
  "quota_aware_action_failed": "無法切換額度感知模式。",
@@ -1136,7 +1136,7 @@
1136
1136
  "claude_beginner_disabled_msg": "Beginner Mode disabled. Restart Claude Code to apply.",
1137
1137
  "claude_beginner_action_failed": "Could not change Beginner Mode",
1138
1138
  "quota_aware_menu": "Quota-Aware Mode",
1139
- "quota_aware_tooltip": "When quota is running low, Claude tells you how much remains and when it resets before starting a large task, so you can choose a smaller part now or wait for the reset.",
1139
+ "quota_aware_tooltip": "When quota is running low, Claude tells you how much remains and when it resets before starting a large task, so you can choose a smaller part now or wait for the reset. Claude is also notified of official Claude Code or Codex service incidents. Claude also gets a short quota overview at the start of each conversation.",
1140
1140
  "quota_aware_enabled_msg": "Quota-Aware Mode enabled. Restart Claude Code to apply.",
1141
1141
  "quota_aware_disabled_msg": "Quota-Aware Mode disabled. Restart Claude Code to apply.",
1142
1142
  "quota_aware_action_failed": "Could not change Quota-Aware Mode.",
@@ -1716,7 +1716,7 @@
1716
1716
  "claude_beginner_disabled_msg": "已关闭新手模式。重启 Claude Code 后生效。",
1717
1717
  "claude_beginner_action_failed": "无法切换新手模式",
1718
1718
  "quota_aware_menu": "额度感知模式",
1719
- "quota_aware_tooltip": "额度快用完时,Claude 开大任务前会先告诉你还剩多少、几点重置,让你选先做小的还是等重置。",
1719
+ "quota_aware_tooltip": "额度快用完时,Claude 开大任务前会先告诉你还剩多少、几点重置,让你选先做小的还是等重置。Claude Code 或 Codex 官方服务出状况时,也会告诉 Claude。每个对话开始时,也会给 Claude 一份各家额度的简短总览。",
1720
1720
  "quota_aware_enabled_msg": "已开启额度感知模式。请重启 Claude Code 应用。",
1721
1721
  "quota_aware_disabled_msg": "已关闭额度感知模式。请重启 Claude Code 应用。",
1722
1722
  "quota_aware_action_failed": "无法切换额度感知模式。",
@@ -2296,7 +2296,7 @@
2296
2296
  "claude_beginner_disabled_msg": "初心者モードを無効にしました。Claude Code を再起動してください。",
2297
2297
  "claude_beginner_action_failed": "初心者モードを切り替えられません",
2298
2298
  "quota_aware_menu": "クォータ認識モード",
2299
- "quota_aware_tooltip": "クォータが少なくなると、大きなタスクを始める前に Claude が残量とリセット時刻を伝えます。小さな部分を先に進めるか、リセットを待つか選べます。",
2299
+ "quota_aware_tooltip": "クォータが少なくなると、大きなタスクを始める前に Claude が残量とリセット時刻を伝えます。小さな部分を先に進めるか、リセットを待つか選べます。Claude Code または Codex の公式サービスに障害が起きたときも Claude に伝えます。会話の開始時にも、各ツールのクォータの短い概要を Claude に伝えます。",
2300
2300
  "quota_aware_enabled_msg": "クォータ認識モードを有効にしました。Claude Code を再起動してください。",
2301
2301
  "quota_aware_disabled_msg": "クォータ認識モードを無効にしました。Claude Code を再起動してください。",
2302
2302
  "quota_aware_action_failed": "クォータ認識モードを切り替えられませんでした。",
@@ -2876,7 +2876,7 @@
2876
2876
  "claude_beginner_disabled_msg": "초보자 모드를 껐습니다. Claude Code를 다시 시작하세요.",
2877
2877
  "claude_beginner_action_failed": "초보자 모드를 전환할 수 없습니다",
2878
2878
  "quota_aware_menu": "할당량 인식 모드",
2879
- "quota_aware_tooltip": "할당량이 부족해지면 Claude가 큰 작업을 시작하기 전에 남은 양과 초기화 시간을 알려 줍니다. 작은 부분을 먼저 진행하거나 초기화를 기다릴 수 있습니다.",
2879
+ "quota_aware_tooltip": "할당량이 부족해지면 Claude가 큰 작업을 시작하기 전에 남은 양과 초기화 시간을 알려 줍니다. 작은 부분을 먼저 진행하거나 초기화를 기다릴 수 있습니다. Claude Code 또는 Codex 공식 서비스에 장애가 생겨도 Claude에게 알려 줍니다. 대화가 시작될 때도 Claude에게 각 도구의 할당량을 간단히 요약해 줍니다.",
2880
2880
  "quota_aware_enabled_msg": "할당량 인식 모드를 켰습니다. Claude Code를 다시 시작해 주세요.",
2881
2881
  "quota_aware_disabled_msg": "할당량 인식 모드를 껐습니다. Claude Code를 다시 시작해 주세요.",
2882
2882
  "quota_aware_action_failed": "할당량 인식 모드를 변경할 수 없습니다.",
@@ -95,7 +95,7 @@ _TERSE_REMINDER_MARKER = "usage-terse-reminder"
95
95
  _TERSE_REMINDER_MARKERS = (_TERSE_REMINDER_MARKER, "usage_terse_reminder")
96
96
  # Independent, opt-in Claude Code UserPromptSubmit hook.
97
97
  QUOTA_AWARE_HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-quota-aware.py"))
98
- QUOTA_AWARE_HOOK_VERSION = "1.5"
98
+ QUOTA_AWARE_HOOK_VERSION = "1.12"
99
99
  QUOTA_AWARE_MATCHER = ""
100
100
  _QUOTA_AWARE_MARKER = "usage-quota-aware"
101
101
  _QUOTA_AWARE_MARKERS = (_QUOTA_AWARE_MARKER, "usage_quota_aware")
@@ -15,12 +15,15 @@ the internal ``retrieveUserQuotaSummary`` endpoint for the quota summary.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import hashlib
18
19
  import json
20
+ import math
19
21
  import os
20
22
  import platform
21
23
  import subprocess
22
24
  import sys
23
25
  import tempfile
26
+ import threading
24
27
  import time
25
28
  import urllib.parse
26
29
  from base64 import b64decode
@@ -41,7 +44,9 @@ from usage_common.subprocess_utils import hidden_console_kwargs
41
44
 
42
45
  CACHE_PATH = Path(os.path.expanduser("~/.usage/agy_quota_cache.json"))
43
46
  AUTH_EXPIRED_PATH = CACHE_PATH.parent / "agy_auth_expired"
47
+ CLI_SIGNED_OUT_PATH = CACHE_PATH.parent / "agy_cli_signed_out"
44
48
  _CLI_LOG_PATH = CACHE_PATH.parent / "agy_usage_cli.log"
49
+ BACKGROUND_AGY_LOCK = threading.Lock()
45
50
 
46
51
 
47
52
  def auth_expired() -> bool:
@@ -54,6 +59,38 @@ def _mark_auth_expired() -> None:
54
59
  AUTH_EXPIRED_PATH.touch()
55
60
 
56
61
 
62
+ def mark_cli_signed_out(before: str | None) -> None:
63
+ after = _credential_fingerprint()
64
+ if after != before:
65
+ return
66
+ with suppress(OSError):
67
+ CLI_SIGNED_OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
68
+ CLI_SIGNED_OUT_PATH.write_text(
69
+ json.dumps({"fingerprint": after, "marked_at": time.time()}),
70
+ encoding="utf-8",
71
+ )
72
+
73
+
74
+ def agy_cli_signed_in(timeout: float) -> bool:
75
+ """Open the gate for a readable credential; bypass cooldown only for a known change."""
76
+ global _cli_retry_after_monotonic
77
+ if not CLI_SIGNED_OUT_PATH.exists():
78
+ return True
79
+ try:
80
+ marker = json.loads(CLI_SIGNED_OUT_PATH.read_text(encoding="utf-8"))
81
+ except (OSError, ValueError, UnicodeError):
82
+ marker = {}
83
+ stored = marker.get("fingerprint") if isinstance(marker, dict) else None
84
+ current = _credential_fingerprint()
85
+ if current is None or current == stored:
86
+ return False
87
+ with suppress(OSError):
88
+ CLI_SIGNED_OUT_PATH.unlink(missing_ok=True)
89
+ if isinstance(stored, str):
90
+ _cli_retry_after_monotonic = 0.0
91
+ return True
92
+
93
+
57
94
  # Legacy OAuth token written by older Antigravity CLI versions. Read-only: we
58
95
  # never write back here (that is the CLI's home and could corrupt its login).
59
96
  _TOKEN_PATH = Path(os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token"))
@@ -121,6 +158,8 @@ class AgyQuotaGroup:
121
158
  models: list[str]
122
159
  weekly: AgyQuotaWindow | None
123
160
  five_hour: AgyQuotaWindow | None
161
+ weekly_fetched_at: str | None = None
162
+ weekly_resets_at: str | None = None
124
163
 
125
164
 
126
165
  @dataclass(frozen=True, slots=True)
@@ -169,19 +208,40 @@ def _probe_via_cli(timeout: float) -> AgyQuotaResult | None:
169
208
  agy = find_agy()
170
209
  if agy is None:
171
210
  return None
172
- if time.monotonic() < _cli_retry_after_monotonic:
173
- return None
174
- # Offline (e.g. just after a sleep wake) the CLI would open a browser sign-in page.
175
- if not google_auth_reachable():
211
+ if not BACKGROUND_AGY_LOCK.acquire(blocking=False):
176
212
  return None
177
- result = _run_usage_command(agy, timeout)
178
- _cli_retry_after_monotonic = (
179
- 0.0 if result is not None else time.monotonic() + _CLI_FAILURE_COOLDOWN_SECONDS
180
- )
181
- return result
213
+ try:
214
+ if not agy_cli_signed_in(timeout) or time.monotonic() < _cli_retry_after_monotonic:
215
+ return None
216
+ # Offline, the CLI would open a browser sign-in page.
217
+ if not google_auth_reachable():
218
+ return None
219
+ before = _credential_fingerprint()
220
+ log_offset = 0
221
+ with suppress(OSError):
222
+ log_offset = _CLI_LOG_PATH.stat().st_size
223
+ result = _run_usage_command(agy, timeout, before)
224
+ if result is None:
225
+ with suppress(OSError):
226
+ with _CLI_LOG_PATH.open("rb") as log:
227
+ size = log.seek(0, os.SEEK_END)
228
+ start = log_offset if size >= log_offset else 0
229
+ log.seek(max(start, size - 65536))
230
+ tail = log.read()
231
+ if (
232
+ b"Print mode: not authenticated" in tail
233
+ or b"triggering interactive OAuth" in tail
234
+ ):
235
+ mark_cli_signed_out(before)
236
+ _cli_retry_after_monotonic = (
237
+ 0.0 if result is not None else time.monotonic() + _CLI_FAILURE_COOLDOWN_SECONDS
238
+ )
239
+ return result
240
+ finally:
241
+ BACKGROUND_AGY_LOCK.release()
182
242
 
183
243
 
184
- def _run_usage_command(agy: str, timeout: float) -> AgyQuotaResult | None:
244
+ def _run_usage_command(agy: str, timeout: float, before: str | None) -> AgyQuotaResult | None:
185
245
  command = [agy, "-p", "/usage", "--output-format", "json", "--log-file", str(_CLI_LOG_PATH)]
186
246
  try:
187
247
  completed = subprocess.run(
@@ -193,8 +253,19 @@ def _run_usage_command(agy: str, timeout: float) -> AgyQuotaResult | None:
193
253
  env=cli_env(agy),
194
254
  **hidden_console_kwargs(),
195
255
  )
256
+ except subprocess.TimeoutExpired as exc:
257
+ if "Authentication required" in str(exc.stdout) or "Authentication required" in str(
258
+ exc.stderr
259
+ ):
260
+ mark_cli_signed_out(before)
261
+ return None
196
262
  except (OSError, subprocess.SubprocessError):
197
263
  return None
264
+ if "Authentication required" in str(completed.stdout) or "Authentication required" in str(
265
+ getattr(completed, "stderr", "")
266
+ ):
267
+ mark_cli_signed_out(before)
268
+ return None
198
269
  if completed.returncode != 0:
199
270
  return None
200
271
  try:
@@ -226,11 +297,57 @@ def load_quota(max_age_minutes: float = 5) -> AgyQuotaResult | None:
226
297
  probed = probe_quota()
227
298
  if probed is None:
228
299
  return cached
300
+ probed = _carry_missing_weekly(probed, cached)
229
301
  probed = _carry_missing_groups(probed, cached)
230
302
  _write_cache(probed)
231
303
  return probed
232
304
 
233
305
 
306
+ def _carry_missing_weekly(probed: AgyQuotaResult, cached: AgyQuotaResult | None) -> AgyQuotaResult:
307
+ """Retain a missing weekly window until its cached reset, without refreshing its age."""
308
+ if cached is None:
309
+ return probed
310
+ cached_at = _parse_timestamp(cached.fetched_at)
311
+ probed_at = _parse_timestamp(probed.fetched_at)
312
+ if cached_at is None or probed_at is None:
313
+ return probed
314
+ previous = {group.name: group for group in cached.groups}
315
+ groups = []
316
+ for group in probed.groups:
317
+ old = previous.get(group.name)
318
+ window = old.weekly if old is not None else None
319
+ if (
320
+ group.weekly is None
321
+ and old is not None
322
+ and window is not None
323
+ and window.resets_in_minutes is not None
324
+ ):
325
+ resets_at = (
326
+ _parse_timestamp(old.weekly_resets_at)
327
+ if old.weekly_resets_at is not None
328
+ else cached_at + timedelta(minutes=window.resets_in_minutes)
329
+ )
330
+ if resets_at is not None and resets_at > probed_at:
331
+ minutes = max(0, math.ceil((resets_at - probed_at).total_seconds() / 60))
332
+ group = replace(
333
+ group,
334
+ weekly=replace(
335
+ window, resets_in_minutes=minutes, resets_in=_format_resets_in(minutes)
336
+ ),
337
+ weekly_fetched_at=old.weekly_fetched_at or cached.fetched_at,
338
+ weekly_resets_at=resets_at.isoformat(),
339
+ )
340
+ groups.append(group)
341
+ return replace(probed, groups=groups)
342
+
343
+
344
+ def _age_window(window: AgyQuotaWindow | None, elapsed: int) -> AgyQuotaWindow | None:
345
+ if window is None or window.resets_in_minutes is None or elapsed == 0:
346
+ return window
347
+ minutes = max(0, window.resets_in_minutes - elapsed)
348
+ return replace(window, resets_in_minutes=minutes, resets_in=_format_resets_in(minutes))
349
+
350
+
234
351
  def _carry_missing_groups(probed: AgyQuotaResult, cached: AgyQuotaResult | None) -> AgyQuotaResult:
235
352
  """Keep a group the API skipped this once, so the group toggle has something to show.
236
353
 
@@ -261,14 +378,12 @@ def _carry_missing_groups(probed: AgyQuotaResult, cached: AgyQuotaResult | None)
261
378
  else 0
262
379
  )
263
380
 
264
- def age_window(window: AgyQuotaWindow | None) -> AgyQuotaWindow | None:
265
- if window is None or window.resets_in_minutes is None or elapsed == 0:
266
- return window
267
- minutes = max(0, window.resets_in_minutes - elapsed)
268
- return replace(window, resets_in_minutes=minutes, resets_in=_format_resets_in(minutes))
269
-
270
381
  missing = [
271
- replace(group, weekly=age_window(group.weekly), five_hour=age_window(group.five_hour))
382
+ replace(
383
+ group,
384
+ weekly=_age_window(group.weekly, elapsed),
385
+ five_hour=_age_window(group.five_hour, elapsed),
386
+ )
272
387
  for group in missing
273
388
  ]
274
389
  order = {group.name: index for index, group in enumerate(cached.groups)}
@@ -276,6 +391,21 @@ def _carry_missing_groups(probed: AgyQuotaResult, cached: AgyQuotaResult | None)
276
391
  return AgyQuotaResult(groups=groups, fetched_at=probed.fetched_at)
277
392
 
278
393
 
394
+ def _credential_fingerprint() -> str | None:
395
+ """Hash only the stored credential, without resolving or refreshing a token."""
396
+ token_data: object = None
397
+ if sys.platform == "darwin":
398
+ token_data = _read_macos_credential()
399
+ if not _has_usable_token(token_data):
400
+ token_data = _read_token_file()
401
+ if not _has_usable_token(token_data) and sys.platform == "win32":
402
+ token_data = _read_windows_credential()
403
+ token = token_data.get("token") if isinstance(token_data, dict) else None
404
+ if not isinstance(token, dict):
405
+ return None
406
+ return hashlib.sha256(json.dumps(token, sort_keys=True).encode()).hexdigest()
407
+
408
+
279
409
  def _resolve_access_token(timeout: float) -> str | None:
280
410
  """Return a usable access token, refreshing when stale.
281
411
 
@@ -845,7 +975,31 @@ def _read_cache() -> AgyQuotaResult | None:
845
975
  return None
846
976
  except OSError:
847
977
  return None
848
- return _result_from_payload(payload)
978
+ result = _result_from_payload(payload)
979
+ if result is None:
980
+ return None
981
+ fetched_at = _parse_timestamp(result.fetched_at)
982
+ assert fetched_at is not None
983
+ now = datetime.now(UTC)
984
+
985
+ def weekly_expired(group: AgyQuotaGroup) -> bool:
986
+ if group.weekly_resets_at is not None:
987
+ resets_at = _parse_timestamp(group.weekly_resets_at)
988
+ if resets_at is not None:
989
+ return resets_at <= now
990
+ return (
991
+ group.weekly is not None
992
+ and group.weekly.resets_in_minutes is not None
993
+ and fetched_at + timedelta(minutes=group.weekly.resets_in_minutes) <= now
994
+ )
995
+
996
+ groups = [
997
+ replace(group, weekly=None, weekly_fetched_at=None, weekly_resets_at=None)
998
+ if group.weekly_fetched_at is not None and weekly_expired(group)
999
+ else group
1000
+ for group in result.groups
1001
+ ]
1002
+ return replace(result, groups=groups)
849
1003
 
850
1004
 
851
1005
  def _write_cache(result: AgyQuotaResult) -> None:
@@ -874,6 +1028,12 @@ def _result_to_payload(result: AgyQuotaResult) -> dict[str, object]:
874
1028
  "models": group.models,
875
1029
  "weekly": _window_to_payload(group.weekly),
876
1030
  "five_hour": _window_to_payload(group.five_hour),
1031
+ **(
1032
+ {"weekly_fetched_at": group.weekly_fetched_at}
1033
+ if group.weekly_fetched_at
1034
+ else {}
1035
+ ),
1036
+ **({"weekly_resets_at": group.weekly_resets_at} if group.weekly_resets_at else {}),
877
1037
  }
878
1038
  for group in result.groups
879
1039
  ],
@@ -917,6 +1077,8 @@ def _group_from_payload(payload: object) -> AgyQuotaGroup | None:
917
1077
  models = payload.get("models")
918
1078
  weekly = _window_from_payload(payload.get("weekly"))
919
1079
  five_hour = _window_from_payload(payload.get("five_hour"))
1080
+ weekly_fetched_at = payload.get("weekly_fetched_at")
1081
+ weekly_resets_at = payload.get("weekly_resets_at")
920
1082
  if (
921
1083
  not isinstance(name, str)
922
1084
  or not name
@@ -926,9 +1088,29 @@ def _group_from_payload(payload: object) -> AgyQuotaGroup | None:
926
1088
  or "five_hour" not in payload
927
1089
  or (payload["weekly"] is not None and weekly is None)
928
1090
  or (payload["five_hour"] is not None and five_hour is None)
1091
+ or (
1092
+ weekly_fetched_at is not None
1093
+ and (
1094
+ not isinstance(weekly_fetched_at, str)
1095
+ or _parse_timestamp(weekly_fetched_at) is None
1096
+ )
1097
+ )
1098
+ or (
1099
+ weekly_resets_at is not None
1100
+ and (
1101
+ not isinstance(weekly_resets_at, str) or _parse_timestamp(weekly_resets_at) is None
1102
+ )
1103
+ )
929
1104
  ):
930
1105
  return None
931
- return AgyQuotaGroup(name=name, models=models, weekly=weekly, five_hour=five_hour)
1106
+ return AgyQuotaGroup(
1107
+ name=name,
1108
+ models=models,
1109
+ weekly=weekly,
1110
+ five_hour=five_hour,
1111
+ weekly_fetched_at=weekly_fetched_at,
1112
+ weekly_resets_at=weekly_resets_at,
1113
+ )
932
1114
 
933
1115
 
934
1116
  def _window_from_payload(payload: object) -> AgyQuotaWindow | None:
@@ -90,7 +90,12 @@ def project_quota(
90
90
  )
91
91
  current_time = time.time() if now is None else now
92
92
  age_minutes = _cache_age_minutes(quota.fetched_at, current_time)
93
- stale = _stale_state(quota.fetched_at, current_time, language)
93
+ stale = _stale_state(
94
+ selected.weekly_fetched_at or quota.fetched_at,
95
+ current_time,
96
+ language,
97
+ force=selected.weekly_fetched_at is not None,
98
+ )
94
99
  session_forecast: float | None = None
95
100
  weekly_forecast: float | None = None
96
101
  if stale is None and burn_rate_trackers is not None:
@@ -363,7 +368,9 @@ def _window_row(
363
368
  )
364
369
 
365
370
 
366
- def _stale_state(fetched_at: str, now: float, language: str) -> AgyStaleState | None:
371
+ def _stale_state(
372
+ fetched_at: str, now: float, language: str, *, force: bool = False
373
+ ) -> AgyStaleState | None:
367
374
  if auth_expired():
368
375
  return {
369
376
  "ageText": _t(language, "agy_signin_expired"),
@@ -373,7 +380,7 @@ def _stale_state(fetched_at: str, now: float, language: str) -> AgyStaleState |
373
380
  age_seconds = now - parse_iso8601_utc_or_raise(fetched_at).timestamp()
374
381
  except (TypeError, ValueError):
375
382
  return None
376
- if age_seconds <= AGY_STALE_SECONDS:
383
+ if age_seconds <= AGY_STALE_SECONDS and not force:
377
384
  return None
378
385
  if age_seconds < 3600:
379
386
  return cast(
@@ -36,6 +36,7 @@ from menubar.prefs import (
36
36
  from quota import quota_snapshot
37
37
  from quota.burn_rate import BurnRateTracker
38
38
  from quota.usage_rate import UsageRateTracker
39
+ from usage_common import service_status
39
40
  from usage_common.service_status import CLAUDE_STATUS, CODEX_STATUS, get_service_status
40
41
 
41
42
  logger = logging.getLogger(__name__)
@@ -197,6 +198,8 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
197
198
  get_service_status(CLAUDE_STATUS),
198
199
  get_service_status(CODEX_STATUS),
199
200
  )
201
+ if not app.mock:
202
+ service_status.write_snapshot(service_statuses)
200
203
  group = int(app.tracker.group())
201
204
  state = menubar_state.build_popover_state(
202
205
  outcome=outcome,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usage-cli"
3
- version = "0.34.16"
3
+ version = "0.34.17"
4
4
  description = "usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -15,6 +15,7 @@ import threading
15
15
  import time
16
16
  from pathlib import Path
17
17
 
18
+ from loaders import agy_quota_probe
18
19
  from menubar.agy import AgyRefreshResult
19
20
  from menubar.prefs import _agy_window_keeper_enabled
20
21
  from quota import keeper_outcome
@@ -37,6 +38,10 @@ _lock = threading.Lock()
37
38
  _ping_in_flight = False
38
39
 
39
40
 
41
+ class _PingSkipped(Exception):
42
+ """The background safety gate refused this launch."""
43
+
44
+
40
45
  def should_ping(
41
46
  now: float,
42
47
  enabled: bool,
@@ -83,18 +88,32 @@ def _release() -> None:
83
88
 
84
89
 
85
90
  def _run_agy_ping(agy_bin: str) -> subprocess.CompletedProcess[str]:
86
- return subprocess.run( # noqa: S603 - resolved local Antigravity CLI
87
- [agy_bin, "-p", "ok", "--model", AGY_MODEL],
88
- stdin=subprocess.DEVNULL,
89
- capture_output=True,
90
- encoding="utf-8",
91
- errors="replace",
92
- timeout=PING_TIMEOUT_SECONDS,
93
- cwd=os.path.expanduser("~"),
94
- check=False,
95
- env=cli_env(agy_bin),
96
- **hidden_console_kwargs(),
97
- )
91
+ if not agy_quota_probe.BACKGROUND_AGY_LOCK.acquire(blocking=False):
92
+ raise _PingSkipped("background agy is busy")
93
+ try:
94
+ before = agy_quota_probe._credential_fingerprint()
95
+ if not agy_quota_probe.agy_cli_signed_in(PING_TIMEOUT_SECONDS):
96
+ raise _PingSkipped("CLI is signed out")
97
+ result = subprocess.run( # noqa: S603 - resolved local Antigravity CLI
98
+ [agy_bin, "-p", "ok", "--model", AGY_MODEL],
99
+ stdin=subprocess.DEVNULL,
100
+ capture_output=True,
101
+ encoding="utf-8",
102
+ errors="replace",
103
+ timeout=PING_TIMEOUT_SECONDS,
104
+ cwd=os.path.expanduser("~"),
105
+ check=False,
106
+ env=cli_env(agy_bin),
107
+ **hidden_console_kwargs(),
108
+ )
109
+ output = f"{result.stdout or ''}\n{result.stderr or ''}".lower()
110
+ if result.returncode != 0 and (
111
+ "authentication required" in output or "authentication failed" in output
112
+ ):
113
+ agy_quota_probe.mark_cli_signed_out(before)
114
+ return result
115
+ finally:
116
+ agy_quota_probe.BACKGROUND_AGY_LOCK.release()
98
117
 
99
118
 
100
119
  def _ping_worker(started_at: float) -> None:
@@ -102,6 +121,20 @@ def _ping_worker(started_at: float) -> None:
102
121
  binary = _resolve_agy_bin()
103
122
  result = _run_agy_ping(binary) if binary is not None else FileNotFoundError()
104
123
  fields = keeper_outcome.classify(result)
124
+ except _PingSkipped as exc:
125
+ _debug_log(f"Antigravity ping skipped: {exc}")
126
+ # A probe may win the shared lock after dispatch. Allow the next refresh
127
+ # to retry, and preserve any previous outcome rather than recording failure.
128
+ try:
129
+ keeper_outcome.update_state(
130
+ AGY_WINDOW_KEEPER_STATE_PATH,
131
+ lambda state: (
132
+ {"last_ping_at": None} if state.get("last_ping_at") == started_at else {}
133
+ ),
134
+ )
135
+ finally:
136
+ _release()
137
+ return
105
138
  except Exception as exc:
106
139
  fields = keeper_outcome.classify(exc)
107
140
  try:
@@ -142,6 +175,12 @@ def maybe_ping(result: AgyRefreshResult, mock: bool) -> None:
142
175
  # leave the state untouched so the next refresh tries again.
143
176
  if not google_auth_reachable():
144
177
  return
178
+ if agy_quota_probe.BACKGROUND_AGY_LOCK.locked():
179
+ _debug_log("Antigravity ping skipped: background agy is busy")
180
+ return
181
+ if not agy_quota_probe.agy_cli_signed_in(PING_TIMEOUT_SECONDS):
182
+ _debug_log("Antigravity ping skipped: CLI is signed out")
183
+ return
145
184
  if not _try_acquire():
146
185
  return
147
186
  keeper_outcome.update_state(
@@ -128,13 +128,23 @@ def _status_antigravity(now: int) -> dict[str, Any]:
128
128
  ),
129
129
  }
130
130
 
131
+ def weekly_status(group: agy_quota_probe.AgyQuotaGroup) -> dict[str, Any]:
132
+ window = window_status(group.weekly)
133
+ if group.weekly_fetched_at is not None:
134
+ window["updated_at"] = group.weekly_fetched_at
135
+ window["age_seconds"] = _age_seconds(group.weekly_fetched_at, now)
136
+ resets_at = parse_optional_iso8601_utc(group.weekly_resets_at)
137
+ if resets_at is not None:
138
+ window["resets_in_seconds"] = int(max(0, resets_at.timestamp() - now))
139
+ return window
140
+
131
141
  return {
132
142
  "available": True,
133
143
  "groups": [
134
144
  {
135
145
  "name": group.name,
136
146
  "five_hour": window_status(group.five_hour),
137
- "seven_day": window_status(group.weekly),
147
+ "seven_day": weekly_status(group),
138
148
  }
139
149
  for group in quota.groups
140
150
  ],