usage-cli 0.30.23__tar.gz → 0.31.0__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 (224) hide show
  1. {usage_cli-0.30.23/usage_cli.egg-info → usage_cli-0.31.0}/PKG-INFO +4 -4
  2. {usage_cli-0.30.23 → usage_cli-0.31.0}/README.md +3 -3
  3. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/aggregator.py +1 -0
  4. usage_cli-0.31.0/analyzer/dispatch_ledger.py +451 -0
  5. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/reporter.py +24 -12
  6. {usage_cli-0.30.23 → usage_cli-0.31.0}/i18n.json +65 -0
  7. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_loader.py +39 -5
  8. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/__init__.py +14 -0
  9. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/dynamic_height.py +34 -11
  10. {usage_cli-0.30.23 → usage_cli-0.31.0}/pyproject.toml +1 -1
  11. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_aggregator.py +20 -1
  12. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_loader.py +98 -4
  13. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_analyzer_pipeline.py +1 -0
  14. usage_cli-0.31.0/tests/test_dispatch_ledger.py +312 -0
  15. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_dynamic_height.py +6 -3
  16. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_html_report_snapshot.py +2 -0
  17. usage_cli-0.31.0/tests/test_panel_footer_layout.py +44 -0
  18. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panels.py +3 -0
  19. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_reporter_build_data.py +4 -1
  20. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_wintray.py +217 -2
  21. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/html_report.py +5 -2
  22. usage_cli-0.31.0/ui/report_ledger.py +74 -0
  23. {usage_cli-0.30.23 → usage_cli-0.31.0/usage_cli.egg-info}/PKG-INFO +4 -4
  24. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/SOURCES.txt +4 -0
  25. {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/app.py +129 -31
  26. {usage_cli-0.30.23 → usage_cli-0.31.0}/LICENSE +0 -0
  27. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/__init__.py +0 -0
  28. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/agy.py +0 -0
  29. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/claude.py +0 -0
  30. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/codex.py +0 -0
  31. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/grok.py +0 -0
  32. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/muse.py +0 -0
  33. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/rate_limits.py +0 -0
  34. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/registry.py +0 -0
  35. {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/types.py +0 -0
  36. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/__init__.py +0 -0
  37. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/blocks.py +0 -0
  38. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/diagnoser.py +0 -0
  39. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/insights.py +0 -0
  40. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/persona_loader.py +0 -0
  41. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/subscription.py +0 -0
  42. {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/usage_snapshot.py +0 -0
  43. {usage_cli-0.30.23 → usage_cli-0.31.0}/doctor.py +0 -0
  44. {usage_cli-0.30.23 → usage_cli-0.31.0}/fsevents_watch.py +0 -0
  45. {usage_cli-0.30.23 → usage_cli-0.31.0}/i18n.py +0 -0
  46. {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/__init__.py +0 -0
  47. {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/login_item.py +0 -0
  48. {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/session_hooks.py +0 -0
  49. {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/setup_hook.py +0 -0
  50. {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/statusline_settings.py +0 -0
  51. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/__init__.py +0 -0
  52. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_disk_cache.py +0 -0
  53. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_quota_probe.py +0 -0
  54. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/cache_quarantine.py +0 -0
  55. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/claude_paths.py +0 -0
  56. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_disk_cache.py +0 -0
  57. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_events.py +0 -0
  58. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_fork_replay.py +0 -0
  59. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_loader.py +0 -0
  60. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_paths.py +0 -0
  61. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/disk_cache_common.py +0 -0
  62. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/disk_cache_lifecycle.py +0 -0
  63. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/grok_loader.py +0 -0
  64. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/grok_quota_probe.py +0 -0
  65. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/history_disk_cache.py +0 -0
  66. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/history_loader.py +0 -0
  67. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/jsonl_limits.py +0 -0
  68. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/jsonl_utils.py +0 -0
  69. {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/muse_loader.py +0 -0
  70. {usage_cli-0.30.23 → usage_cli-0.31.0}/main.py +0 -0
  71. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/__init__.py +0 -0
  72. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/actions.py +0 -0
  73. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/agy.py +0 -0
  74. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/app.py +0 -0
  75. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/chrome.py +0 -0
  76. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/grok.py +0 -0
  77. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/menu.py +0 -0
  78. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/notify.py +0 -0
  79. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/popover.py +0 -0
  80. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/prefs.py +0 -0
  81. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/refresh.py +0 -0
  82. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/state.py +0 -0
  83. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/title.py +0 -0
  84. {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/update.py +0 -0
  85. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/base.py +0 -0
  86. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_scale.py +0 -0
  87. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_window.py +0 -0
  88. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_window_state.py +0 -0
  89. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/payload.py +0 -0
  90. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/web_panel.py +0 -0
  91. {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/window_drag.py +0 -0
  92. {usage_cli-0.30.23 → usage_cli-0.31.0}/prefs.py +0 -0
  93. {usage_cli-0.30.23 → usage_cli-0.31.0}/pricing.py +0 -0
  94. {usage_cli-0.30.23 → usage_cli-0.31.0}/project_resolver.py +0 -0
  95. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/__init__.py +0 -0
  96. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/agy_window_keeper.py +0 -0
  97. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/burn_rate.py +0 -0
  98. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/codex_window_keeper.py +0 -0
  99. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/usage_rate.py +0 -0
  100. {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/window_keeper.py +0 -0
  101. {usage_cli-0.30.23 → usage_cli-0.31.0}/service_status.py +0 -0
  102. {usage_cli-0.30.23 → usage_cli-0.31.0}/setup.cfg +0 -0
  103. {usage_cli-0.30.23 → usage_cli-0.31.0}/setup_app.py +0 -0
  104. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_claude.py +0 -0
  105. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_codex.py +0 -0
  106. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_rate_limits.py +0 -0
  107. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_adapter.py +0 -0
  108. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_disk_cache.py +0 -0
  109. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_quota_probe.py +0 -0
  110. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_window_keeper.py +0 -0
  111. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_analyzer_blocks.py +0 -0
  112. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_burn_rate.py +0 -0
  113. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_cache_quarantine.py +0 -0
  114. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_claude_paths.py +0 -0
  115. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_consistency.py +0 -0
  116. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_loader.py +0 -0
  117. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_window_keeper.py +0 -0
  118. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_diagnoser.py +0 -0
  119. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_disk_cache_shards.py +0 -0
  120. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_doctor.py +0 -0
  121. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_fsevents_watch.py +0 -0
  122. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_grok_loader.py +0 -0
  123. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_grok_quota_probe.py +0 -0
  124. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_history_loader.py +0 -0
  125. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_hook_scripts_are_standalone.py +0 -0
  126. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_format_fallback.py +0 -0
  127. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_key_parity.py +0 -0
  128. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_packaged_path.py +0 -0
  129. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_insights.py +0 -0
  130. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_jsonl_utils.py +0 -0
  131. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_login_item.py +0 -0
  132. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_main.py +0 -0
  133. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar.py +0 -0
  134. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_agy.py +0 -0
  135. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_grok.py +0 -0
  136. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_prefs.py +0 -0
  137. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_state.py +0 -0
  138. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_muse_loader.py +0 -0
  139. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_packaged_resources.py +0 -0
  140. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_packaging.py +0 -0
  141. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panel_scale.py +0 -0
  142. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panel_window_state.py +0 -0
  143. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panels_lazy_import.py +0 -0
  144. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_persona_loader.py +0 -0
  145. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_pricing.py +0 -0
  146. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_project_resolver.py +0 -0
  147. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_report_daily_chart.py +0 -0
  148. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_report_filter_js.py +0 -0
  149. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_service_status.py +0 -0
  150. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_session_resume.py +0 -0
  151. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_app.py +0 -0
  152. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook.py +0 -0
  153. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_coexistence.py +0 -0
  154. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_resume.py +0 -0
  155. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_terse.py +0 -0
  156. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_subprocess_utils.py +0 -0
  157. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_subscription.py +0 -0
  158. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_time_utils.py +0 -0
  159. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_tui.py +0 -0
  160. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_tui_sprite.py +0 -0
  161. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_ui_tables.py +0 -0
  162. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_checker.py +0 -0
  163. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_gate.py +0 -0
  164. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_release_notes.py +0 -0
  165. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_cli.py +0 -0
  166. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_client.py +0 -0
  167. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_diagnosis_snapshot.py +0 -0
  168. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_dir_sweeper.py +0 -0
  169. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_lang.py +0 -0
  170. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_logging.py +0 -0
  171. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_notifications.py +0 -0
  172. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_rate.py +0 -0
  173. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_snapshot.py +0 -0
  174. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_snapshot_replay.py +0 -0
  175. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline.py +0 -0
  176. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_agy.py +0 -0
  177. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_forwarder.py +0 -0
  178. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_grok.py +0 -0
  179. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_terse_mode.py +0 -0
  180. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_terse_reminder.py +0 -0
  181. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_web_panel_payload.py +0 -0
  182. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_wheel_packaging.py +0 -0
  183. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_win_login_item.py +0 -0
  184. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_window_keeper.py +0 -0
  185. {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_windows_manifest.py +0 -0
  186. {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/__init__.py +0 -0
  187. {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/app.py +0 -0
  188. {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/sprite.py +0 -0
  189. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/__init__.py +0 -0
  190. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_charts.py +0 -0
  191. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_daily_chart.py +0 -0
  192. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_filter.py +0 -0
  193. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_scripts.py +0 -0
  194. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_styles.py +0 -0
  195. {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/tables.py +0 -0
  196. {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/__init__.py +0 -0
  197. {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/checker.py +0 -0
  198. {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/gate.py +0 -0
  199. {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/release_notes.py +0 -0
  200. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/dependency_links.txt +0 -0
  201. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/entry_points.txt +0 -0
  202. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/requires.txt +0 -0
  203. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/top_level.txt +0 -0
  204. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.py +0 -0
  205. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_client.py +0 -0
  206. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/__init__.py +0 -0
  207. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/subprocess_utils.py +0 -0
  208. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/time_utils.py +0 -0
  209. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_dir_sweeper.py +0 -0
  210. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_lang.py +0 -0
  211. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_logging.py +0 -0
  212. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_diagnosis_snapshot.py +0 -0
  213. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_notifications.py +0 -0
  214. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_session_resume.py +0 -0
  215. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline.py +0 -0
  216. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_agy.py +0 -0
  217. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_forwarder.py +0 -0
  218. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_grok.py +0 -0
  219. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_terse_mode.py +0 -0
  220. {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_terse_reminder.py +0 -0
  221. {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/__init__.py +0 -0
  222. {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/login_item.py +0 -0
  223. {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/menu.py +0 -0
  224. {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usage-cli
3
- Version: 0.30.23
3
+ Version: 0.31.0
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
@@ -87,7 +87,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
87
87
 
88
88
  ### Experience & Customization
89
89
 
90
- - **14 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, and Catppuccin (official palette, all four flavors).
90
+ - **15 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, Sketchbook, and Catppuccin (official palette, all four flavors).
91
91
  - **Place the Panel Anywhere:** The panel is no longer pinned under the menu bar icon. Drag it from any empty spot to wherever you want it, and it reopens there next time. It stays put when another app takes focus — a second click on the menu bar icon, or Escape, closes it.
92
92
  - **Drag to Reorder:** Grab any quota card and drag it up or down to swap the order — the arrangement is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
93
93
  - **Automatic Localization:** UI text is available in Traditional Chinese, Simplified Chinese, English, Japanese, and Korean, automatically matching your system settings.
@@ -147,7 +147,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
147
147
 
148
148
  Windows has the full core experience: the system-tray UI, Claude Code status-line hook, and Codex history parsing all work natively. Download `usage-windows.zip` from the [latest GitHub Release](https://github.com/aqua5230/usage/releases/latest), unzip it, then run `usage.exe`—no installer is needed. If SmartScreen shows **Windows protected your PC** on first launch, click **More info** → **Run anyway**. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.
149
149
 
150
- The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 14 quota themes available on macOS (Default plus the other thirteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
150
+ The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 15 quota themes available on macOS (Default plus the other fourteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
151
151
 
152
152
  Windows differences: the panel opens at the bottom-right of the working area rather than next to the tray icon; update prompts use a system Yes/No dialog.
153
153
 
@@ -164,7 +164,7 @@ Privacy policy: this program will not transfer any information to other networke
164
164
 
165
165
  ## Theme Gallery
166
166
 
167
- Switch between **14 visual themes** directly from the UI:
167
+ Switch between **15 visual themes** directly from the UI:
168
168
 
169
169
  <p align="center">
170
170
  <img src="docs/classic.en.png" width="32%" alt="Classic theme" />
@@ -67,7 +67,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
67
67
 
68
68
  ### Experience & Customization
69
69
 
70
- - **14 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, and Catppuccin (official palette, all four flavors).
70
+ - **15 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, Sketchbook, and Catppuccin (official palette, all four flavors).
71
71
  - **Place the Panel Anywhere:** The panel is no longer pinned under the menu bar icon. Drag it from any empty spot to wherever you want it, and it reopens there next time. It stays put when another app takes focus — a second click on the menu bar icon, or Escape, closes it.
72
72
  - **Drag to Reorder:** Grab any quota card and drag it up or down to swap the order — the arrangement is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
73
73
  - **Automatic Localization:** UI text is available in Traditional Chinese, Simplified Chinese, English, Japanese, and Korean, automatically matching your system settings.
@@ -127,7 +127,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
127
127
 
128
128
  Windows has the full core experience: the system-tray UI, Claude Code status-line hook, and Codex history parsing all work natively. Download `usage-windows.zip` from the [latest GitHub Release](https://github.com/aqua5230/usage/releases/latest), unzip it, then run `usage.exe`—no installer is needed. If SmartScreen shows **Windows protected your PC** on first launch, click **More info** → **Run anyway**. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.
129
129
 
130
- The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 14 quota themes available on macOS (Default plus the other thirteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
130
+ The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 15 quota themes available on macOS (Default plus the other fourteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
131
131
 
132
132
  Windows differences: the panel opens at the bottom-right of the working area rather than next to the tray icon; update prompts use a system Yes/No dialog.
133
133
 
@@ -144,7 +144,7 @@ Privacy policy: this program will not transfer any information to other networke
144
144
 
145
145
  ## Theme Gallery
146
146
 
147
- Switch between **14 visual themes** directly from the UI:
147
+ Switch between **15 visual themes** directly from the UI:
148
148
 
149
149
  <p align="center">
150
150
  <img src="docs/classic.en.png" width="32%" alt="Classic theme" />
@@ -122,6 +122,7 @@ def aggregate_sessions(entries: list[UsageEntry]) -> list[SessionStats]:
122
122
  start_time=first.timestamp,
123
123
  end_time=last.timestamp,
124
124
  duration_minutes=round(duration, 1),
125
+ agent_id=first.agent_id,
125
126
  )
126
127
  for e in session_entries:
127
128
  cost = calculate_cost(e)
@@ -0,0 +1,451 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+
4
+ """Local dispatch matching and subscription quota estimates for HTML reports."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import defaultdict
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ import json
12
+ from pathlib import Path
13
+ import re
14
+ import sqlite3
15
+ from typing import Any, TypedDict
16
+
17
+ from adapters import rate_limits
18
+ from adapters.types import UsageEntry
19
+ from loaders import agy_loader, agy_quota_probe, codex_loader
20
+ from loaders.claude_paths import claude_config_dirs
21
+
22
+ _DISPATCH = {
23
+ "codex": re.compile(r"(^|[;&|(\s])codex exec"),
24
+ "antigravity": re.compile(r"(^|[;&|(\s])agy -p"),
25
+ }
26
+ _UUID = re.compile(r"\b[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\b")
27
+ _CWD = re.compile(rb'"Cwd":"((?:[^"\\]|\\.)*)"')
28
+ _file_cache: dict[tuple[Path, int, float], list[Dispatch]] = {}
29
+ _codex_cache: dict[Path, tuple[tuple[int, float], Candidate | None]] = {}
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Dispatch:
34
+ timestamp: datetime
35
+ session_id: str
36
+ cwd: str
37
+ command: str
38
+ agent_id: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Candidate:
43
+ agent_id: str
44
+ session_id: str
45
+ timestamp: datetime
46
+ cwd: str
47
+ first_message: str = ""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class QuotaWindow:
52
+ used_pct: float
53
+ resets_at: datetime
54
+ length: timedelta
55
+
56
+
57
+ class LedgerRow(TypedDict):
58
+ date: str
59
+ project: str
60
+ title: str
61
+ dispatch_count: int
62
+ claude_pct: float | None
63
+ codex_pct: float | None
64
+ agy_pct: float | None
65
+ total_pct: float
66
+
67
+
68
+ class LedgerData(TypedDict):
69
+ rows: list[LedgerRow]
70
+ unowned_codex: int
71
+ unowned_agy: int
72
+
73
+
74
+ def _timestamp(value: object) -> datetime | None:
75
+ try:
76
+ if isinstance(value, (float, int)) and not isinstance(value, bool):
77
+ return datetime.fromtimestamp(value, UTC)
78
+ if isinstance(value, str):
79
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
80
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
81
+ except (ValueError, OverflowError):
82
+ pass
83
+ return None
84
+
85
+
86
+ def _dict(value: object) -> dict[str, Any]:
87
+ return value if isinstance(value, dict) else {}
88
+
89
+
90
+ def _claude_paths(cutoff: datetime) -> list[Path]:
91
+ paths: list[Path] = []
92
+ for root in claude_config_dirs():
93
+ for path in (root / "projects").glob("*/*.jsonl"):
94
+ try:
95
+ if path.stat().st_mtime >= cutoff.timestamp():
96
+ paths.append(path)
97
+ except OSError:
98
+ continue
99
+ return paths
100
+
101
+
102
+ def _read_dispatches(path: Path) -> list[Dispatch]:
103
+ try:
104
+ stat = path.stat()
105
+ key = (path, stat.st_size, stat.st_mtime)
106
+ cached = _file_cache.get(key)
107
+ if cached is not None:
108
+ return cached
109
+ found: list[Dispatch] = []
110
+ with path.open(encoding="utf-8", errors="replace") as stream:
111
+ for line in stream:
112
+ if "codex exec" not in line and "agy -p" not in line:
113
+ continue
114
+ try:
115
+ record = _dict(json.loads(line))
116
+ except json.JSONDecodeError:
117
+ continue
118
+ timestamp = _timestamp(record.get("timestamp"))
119
+ if timestamp is None:
120
+ continue
121
+ message = _dict(record.get("message"))
122
+ blocks = message.get("content")
123
+ if not isinstance(blocks, list):
124
+ blocks = [record]
125
+ for block in blocks:
126
+ item = _dict(block)
127
+ if item.get("type") != "tool_use" or item.get("name") != "Bash":
128
+ continue
129
+ command = _dict(item.get("input")).get("command")
130
+ if not isinstance(command, str):
131
+ continue
132
+ for agent_id, pattern in _DISPATCH.items():
133
+ for _ in pattern.finditer(command):
134
+ found.append(
135
+ Dispatch(
136
+ timestamp,
137
+ path.stem,
138
+ str(record.get("cwd") or ""),
139
+ command,
140
+ agent_id,
141
+ )
142
+ )
143
+ for old_key in tuple(_file_cache):
144
+ if old_key[0] == path and old_key != key:
145
+ del _file_cache[old_key]
146
+ _file_cache[key] = found
147
+ return found
148
+ except OSError:
149
+ return []
150
+
151
+
152
+ def _read_codex_candidate(path: Path) -> Candidate | None:
153
+ try:
154
+ stat = path.stat()
155
+ signature = (stat.st_size, stat.st_mtime)
156
+ cached = _codex_cache.get(path)
157
+ if cached is not None and cached[0] == signature:
158
+ return cached[1]
159
+ candidate = None
160
+ with path.open(encoding="utf-8", errors="replace") as stream:
161
+ first = _dict(json.loads(next(stream)))
162
+ meta = _dict(first.get("payload"))
163
+ started = _timestamp(first.get("timestamp"))
164
+ if (
165
+ first.get("type") == "session_meta"
166
+ and meta.get("originator") == "codex_exec"
167
+ and started is not None
168
+ ):
169
+ message = ""
170
+ for line in stream:
171
+ if '"user_message"' not in line:
172
+ continue
173
+ try:
174
+ payload = _dict(_dict(json.loads(line)).get("payload"))
175
+ except json.JSONDecodeError:
176
+ continue
177
+ if payload.get("type") == "user_message":
178
+ message = str(payload.get("message") or "")
179
+ break
180
+ candidate = Candidate(
181
+ "codex",
182
+ str(meta.get("id") or path.stem),
183
+ started,
184
+ str(meta.get("cwd") or ""),
185
+ message,
186
+ )
187
+ _codex_cache[path] = (signature, candidate)
188
+ return candidate
189
+ except (OSError, StopIteration, json.JSONDecodeError):
190
+ return None
191
+
192
+
193
+ def _codex_candidates(cutoff: datetime, now: datetime) -> list[Candidate]:
194
+ day = (cutoff - timedelta(days=1)).date()
195
+ day_dirs = []
196
+ while day <= (now + timedelta(days=1)).date():
197
+ day_dirs.append(codex_loader.SESSIONS_DIR / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}")
198
+ day += timedelta(days=1)
199
+ result: list[Candidate] = []
200
+ for path in (path for day_dir in day_dirs for path in day_dir.glob("*.jsonl")):
201
+ candidate = _read_codex_candidate(path)
202
+ if candidate is not None and cutoff <= candidate.timestamp <= now:
203
+ result.append(candidate)
204
+ return result
205
+
206
+
207
+ def _agy_candidates(cutoff: datetime, now: datetime) -> list[Candidate]:
208
+ result: list[Candidate] = []
209
+ for path in agy_loader.AGY_SESSIONS_DIR.glob("*.db"):
210
+ try:
211
+ if path.stat().st_mtime < cutoff.timestamp():
212
+ continue
213
+ with sqlite3.connect(agy_loader._readonly_sqlite_uri(path), uri=True) as connection:
214
+ started = agy_loader._session_timestamp(connection, path)
215
+ if not cutoff <= started <= now:
216
+ continue
217
+ row = connection.execute(
218
+ 'SELECT step_payload FROM steps WHERE step_payload LIKE \'%"Cwd":"%\' ORDER BY idx LIMIT 1'
219
+ ).fetchone()
220
+ match = _CWD.search(row[0]) if row and isinstance(row[0], bytes) else None
221
+ cwd = (
222
+ json.loads('"' + match.group(1).decode("utf-8", "replace") + '"')
223
+ if match
224
+ else ""
225
+ )
226
+ result.append(
227
+ Candidate(
228
+ "antigravity", path.stem, started, cwd if isinstance(cwd, str) else ""
229
+ )
230
+ )
231
+ except (OSError, sqlite3.Error, json.JSONDecodeError):
232
+ continue
233
+ return result
234
+
235
+
236
+ def match_candidate(
237
+ candidate: Candidate, dispatches: list[Dispatch], known_sessions: set[str]
238
+ ) -> str | None:
239
+ if candidate.agent_id == "codex":
240
+ direct: set[str] = {
241
+ value.lower() for value in _UUID.findall(candidate.cwd + " " + candidate.first_message)
242
+ } & known_sessions
243
+ if len(direct) == 1:
244
+ return next(iter(direct))
245
+ if len(direct) > 1:
246
+ return None
247
+ eligible = [
248
+ dispatch
249
+ for dispatch in dispatches
250
+ if dispatch.agent_id == candidate.agent_id
251
+ and -5 <= (candidate.timestamp - dispatch.timestamp).total_seconds() <= 120
252
+ ]
253
+ owners = {dispatch.session_id for dispatch in eligible}
254
+ if len(owners) > 1:
255
+ eligible = [
256
+ dispatch
257
+ for dispatch in eligible
258
+ if candidate.cwd
259
+ and (candidate.cwd == dispatch.cwd or candidate.cwd in dispatch.command)
260
+ ]
261
+ owners = {dispatch.session_id for dispatch in eligible}
262
+ return next(iter(owners)) if len(owners) == 1 else None
263
+
264
+
265
+ def _window(pct: float | None, reset: object, minutes: float) -> QuotaWindow | None:
266
+ resets_at = _timestamp(reset)
267
+ if pct is None or resets_at is None or minutes <= 0:
268
+ return None
269
+ return QuotaWindow(pct, resets_at, timedelta(minutes=minutes))
270
+
271
+
272
+ def load_windows() -> dict[str, QuotaWindow]:
273
+ windows: dict[str, QuotaWindow] = {}
274
+ claude = rate_limits.load_rate_limits()
275
+ if claude:
276
+ window = _window(claude.seven_day_pct, claude.seven_day_resets_at, 7 * 24 * 60)
277
+ if window:
278
+ windows["claude-code"] = window
279
+ codex = codex_loader.load_rate_limits()
280
+ if codex:
281
+ window = _window(
282
+ codex.seven_day_pct,
283
+ codex.seven_day_resets_at,
284
+ codex.seven_day_window_minutes or 7 * 24 * 60,
285
+ )
286
+ if window:
287
+ windows["codex"] = window
288
+ agy = agy_quota_probe._read_cache()
289
+ if agy:
290
+ fetched = _timestamp(agy.fetched_at)
291
+ if fetched:
292
+ for group in agy.groups:
293
+ minutes = group.weekly.resets_in_minutes
294
+ if minutes is None:
295
+ continue
296
+ window = QuotaWindow(
297
+ 100 - group.weekly.remaining_percent,
298
+ fetched + timedelta(minutes=minutes),
299
+ timedelta(days=7),
300
+ )
301
+ group_key = f"antigravity:{group.name}"
302
+ windows[group_key] = window
303
+ for model in group.models:
304
+ windows[f"antigravity-model:{model.lower()}"] = window
305
+ return windows
306
+
307
+
308
+ def estimate_pct(
309
+ session_tokens: int, total_tokens: int, window: QuotaWindow | None, now: datetime
310
+ ) -> float | None:
311
+ if window is None or window.resets_at <= now or total_tokens <= 0:
312
+ return None
313
+ return session_tokens / total_tokens * window.used_pct
314
+
315
+
316
+ def estimate_sessions(
317
+ entries: list[UsageEntry], windows: dict[str, QuotaWindow], now: datetime
318
+ ) -> dict[tuple[str, str], float | None]:
319
+ totals: dict[str, int] = defaultdict(int)
320
+ sessions: dict[tuple[str, str, str], int] = defaultdict(int)
321
+ for entry in entries:
322
+ group = entry.agent_id
323
+ if entry.agent_id == "antigravity":
324
+ model = entry.model.lower().replace("-", " ")
325
+ matched = next(
326
+ (
327
+ label.removeprefix("antigravity-model:")
328
+ for label in windows
329
+ if label.startswith("antigravity-model:")
330
+ and all(
331
+ word in model for word in label.removeprefix("antigravity-model:").split()
332
+ )
333
+ ),
334
+ None,
335
+ )
336
+ if matched is None:
337
+ continue
338
+ group = next(
339
+ (
340
+ key
341
+ for key, window in windows.items()
342
+ if key.startswith("antigravity:")
343
+ and window is windows[f"antigravity-model:{matched}"]
344
+ ),
345
+ "",
346
+ )
347
+ window = windows.get(group)
348
+ if window is None or not window.resets_at - window.length <= entry.timestamp <= now:
349
+ continue
350
+ tokens = entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens
351
+ totals[group] += tokens
352
+ sessions[(entry.agent_id, entry.session_id, group)] += tokens
353
+ estimates: dict[tuple[str, str], float | None] = {}
354
+ for (agent_id, session_id, group), tokens in sessions.items():
355
+ value = estimate_pct(tokens, totals[group], windows.get(group), now)
356
+ if value is not None:
357
+ key = (agent_id, session_id)
358
+ estimates[key] = (estimates.get(key) or 0.0) + value
359
+ return estimates
360
+
361
+
362
+ def _session_title(path: Path) -> str:
363
+ """Return Claude Code's own ai-title, falling back to the first user text."""
364
+ title = first_text = ""
365
+ try:
366
+ with path.open(encoding="utf-8", errors="replace") as stream:
367
+ for line in stream:
368
+ if '"ai-title"' not in line and (first_text or '"user"' not in line):
369
+ continue
370
+ try:
371
+ item = _dict(json.loads(line))
372
+ except json.JSONDecodeError:
373
+ continue
374
+ if item.get("type") == "ai-title" and isinstance(item.get("aiTitle"), str):
375
+ title = item["aiTitle"].strip()
376
+ elif item.get("type") == "user" and not first_text:
377
+ content = _dict(item.get("message")).get("content")
378
+ blocks = [{"text": content}] if isinstance(content, str) else content
379
+ for block in blocks if isinstance(blocks, list) else []:
380
+ text = _dict(block).get("text")
381
+ if isinstance(text, str) and text.strip():
382
+ first_text = text.strip()[:40]
383
+ break
384
+ except OSError:
385
+ return ""
386
+ return title or first_text
387
+
388
+
389
+ def build_ledger(estimates: dict[tuple[str, str], float | None], now: datetime) -> LedgerData:
390
+ cutoff = now - timedelta(days=7)
391
+ paths = _claude_paths(cutoff)
392
+ dispatches = [
393
+ item for path in paths for item in _read_dispatches(path) if cutoff <= item.timestamp <= now
394
+ ]
395
+ empty: LedgerData = {"rows": [], "unowned_codex": 0, "unowned_agy": 0}
396
+ if not dispatches:
397
+ return empty
398
+ known_sessions = {path.stem for path in paths}
399
+ candidates = _codex_candidates(cutoff - timedelta(seconds=5), now) + _agy_candidates(
400
+ cutoff - timedelta(seconds=5), now
401
+ )
402
+ owned: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
403
+ for candidate in candidates:
404
+ owner = match_candidate(candidate, dispatches, known_sessions)
405
+ if owner is None:
406
+ if candidate.agent_id == "codex":
407
+ empty["unowned_codex"] += 1
408
+ else:
409
+ empty["unowned_agy"] += 1
410
+ else:
411
+ owned[owner][candidate.agent_id].add(candidate.session_id)
412
+ if not owned:
413
+ return empty
414
+ path_by_session = {path.stem: path for path in paths}
415
+ by_owner: dict[str, list[Dispatch]] = defaultdict(list)
416
+ for dispatch in dispatches:
417
+ by_owner[dispatch.session_id].append(dispatch)
418
+ rows: list[tuple[str, LedgerRow]] = []
419
+ for owner, children in owned.items():
420
+ origin = by_owner.get(owner)
421
+ if not origin:
422
+ continue
423
+ shares = {"claude-code": estimates.get(("claude-code", owner))}
424
+ for agent_id in ("codex", "antigravity"):
425
+ values = [estimates.get((agent_id, session_id)) for session_id in children[agent_id]]
426
+ shares[agent_id] = (
427
+ sum(value for value in values if value is not None)
428
+ if any(value is not None for value in values)
429
+ else None
430
+ )
431
+ first = min(origin, key=lambda item: item.timestamp)
432
+ rows.append(
433
+ (
434
+ owner,
435
+ {
436
+ "date": first.timestamp.astimezone().strftime("%Y-%m-%d"),
437
+ "project": Path(first.cwd).name or "unknown",
438
+ "title": "",
439
+ "dispatch_count": len(origin),
440
+ "claude_pct": shares["claude-code"],
441
+ "codex_pct": shares["codex"],
442
+ "agy_pct": shares["antigravity"],
443
+ "total_pct": sum(value for value in shares.values() if value is not None),
444
+ },
445
+ )
446
+ )
447
+ rows.sort(key=lambda item: item[1]["total_pct"], reverse=True)
448
+ for owner, row in rows[:15]:
449
+ row["title"] = _session_title(path_by_session[owner])
450
+ empty["rows"].append(row)
451
+ return empty
@@ -27,7 +27,7 @@ from adapters.types import AgentInfo, UsageEntry
27
27
  from pricing import calculate_cost, is_model_priced
28
28
 
29
29
  from .aggregator import aggregate_sessions
30
- from . import diagnoser, usage_snapshot
30
+ from . import diagnoser, dispatch_ledger, usage_snapshot
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -43,7 +43,7 @@ _YEAR_WEEKS = 53
43
43
  YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
44
44
  YEAR_LEDGER_PATH = YEAR_CACHE_PATH.with_name("year_ledger.json")
45
45
  YEAR_CACHE_TTL_SECONDS = 6 * 3600
46
- _YEAR_CACHE_SCHEMA = 3
46
+ _YEAR_CACHE_SCHEMA = 4
47
47
  _YEAR_LEDGER_SCHEMA = 1
48
48
  _YEAR_LEDGER_TRIM_BUFFER_DAYS = 60
49
49
 
@@ -130,6 +130,7 @@ class TopSessionReportRow(TypedDict):
130
130
  duration_min: float
131
131
  tokens: int
132
132
  cost: float
133
+ weekly_quota_pct: float | None
133
134
 
134
135
 
135
136
  class CubeAgentData(TypedDict):
@@ -241,6 +242,7 @@ class ReportData(_ReportDataRequired, total=False):
241
242
  by_agent_model: list[AgentModelReportRow]
242
243
  cube: CubeReportData
243
244
  sessions: list[SessionRowData]
245
+ dispatch_ledger: dispatch_ledger.LedgerData
244
246
 
245
247
 
246
248
  @dataclass(frozen=True)
@@ -911,6 +913,11 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
911
913
  prev_date_from = date_from - timedelta(days=total_days_for_comparison)
912
914
  hours_back = ((date_to - prev_date_from).days + 2) * 24
913
915
 
916
+ weekly_preloaded = (
917
+ [entry for agent in agents for entry in _load_agent_entries(agent, 8 * 24)]
918
+ if hours_back < 8 * 24
919
+ else None
920
+ )
914
921
  raw_entries: list[UsageEntry] = []
915
922
  for agent in agents:
916
923
  raw_entries.extend(_load_agent_entries(agent, hours_back))
@@ -1193,6 +1200,10 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
1193
1200
  cursor += timedelta(days=1)
1194
1201
 
1195
1202
  top_sessions: list[TopSessionReportRow] = []
1203
+ now = datetime.now().astimezone(timezone.utc)
1204
+ weekly_entries = raw_entries if weekly_preloaded is None else weekly_preloaded
1205
+ windows = dispatch_ledger.load_windows() if weekly_entries else {}
1206
+ estimates = dispatch_ledger.estimate_sessions(weekly_entries, windows, now)
1196
1207
  sessions_by_cost = sorted(
1197
1208
  aggregate_sessions(entries), key=lambda session: session.cost_usd, reverse=True
1198
1209
  )
@@ -1200,16 +1211,16 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
1200
1211
  sessions_by_cost, key=lambda session: session.total_tokens, reverse=True
1201
1212
  )
1202
1213
  for session in sessions_by_tokens[:5]:
1203
- top_sessions.append(
1204
- {
1205
- "start_time": _session_start_text(session.start_time),
1206
- "project": session.project or "unknown",
1207
- "model": session.model or "unknown",
1208
- "duration_min": session.duration_minutes,
1209
- "tokens": session.total_tokens,
1210
- "cost": _round_cost(session.cost_usd),
1211
- }
1212
- )
1214
+ session_row: TopSessionReportRow = {
1215
+ "start_time": _session_start_text(session.start_time),
1216
+ "project": session.project or "unknown",
1217
+ "model": session.model or "unknown",
1218
+ "duration_min": session.duration_minutes,
1219
+ "tokens": session.total_tokens,
1220
+ "cost": _round_cost(session.cost_usd),
1221
+ "weekly_quota_pct": estimates.get((session.agent_id, session.session_id)),
1222
+ }
1223
+ top_sessions.append(session_row)
1213
1224
 
1214
1225
  session_rows: list[SessionRowData] = []
1215
1226
  for session in sessions_by_cost:
@@ -1256,6 +1267,7 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
1256
1267
  "sessions": session_rows,
1257
1268
  "daily_trend": daily_trend,
1258
1269
  "top_sessions": top_sessions,
1270
+ "dispatch_ledger": dispatch_ledger.build_ledger(estimates, now),
1259
1271
  "comparison": comparison,
1260
1272
  "subscriptions": subscription.load_subscriptions(),
1261
1273
  "persona": _load_persona_for_period(period),