usage-cli 0.30.22__tar.gz → 0.30.24__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.
- {usage_cli-0.30.22/usage_cli.egg-info → usage_cli-0.30.24}/PKG-INFO +5 -3
- {usage_cli-0.30.22 → usage_cli-0.30.24}/README.md +4 -2
- usage_cli-0.30.24/adapters/muse.py +43 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/registry.py +2 -2
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/aggregator.py +1 -0
- usage_cli-0.30.24/analyzer/dispatch_ledger.py +451 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/reporter.py +31 -13
- {usage_cli-0.30.22 → usage_cli-0.30.24}/i18n.json +85 -5
- usage_cli-0.30.24/loaders/muse_loader.py +169 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/agy.py +39 -3
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/prefs.py +21 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/state.py +8 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/dynamic_height.py +8 -7
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/web_panel.py +17 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/pricing.py +15 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/pyproject.toml +1 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_aggregator.py +20 -1
- usage_cli-0.30.24/tests/test_dispatch_ledger.py +312 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_dynamic_height.py +4 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_html_report_snapshot.py +17 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_menubar.py +18 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_menubar_agy.py +74 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_menubar_prefs.py +15 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_menubar_state.py +15 -1
- usage_cli-0.30.24/tests/test_muse_loader.py +179 -0
- usage_cli-0.30.24/tests/test_panel_footer_layout.py +43 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_reporter_build_data.py +4 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_web_panel_payload.py +44 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_wintray.py +28 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/html_report.py +6 -2
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/report_daily_chart.py +2 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/report_filter.py +1 -0
- usage_cli-0.30.24/ui/report_ledger.py +74 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/tables.py +8 -1
- {usage_cli-0.30.22 → usage_cli-0.30.24/usage_cli.egg-info}/PKG-INFO +5 -3
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.egg-info/SOURCES.txt +7 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.py +8 -2
- {usage_cli-0.30.22 → usage_cli-0.30.24}/wintray/app.py +12 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/LICENSE +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/agy.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/claude.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/codex.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/grok.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/rate_limits.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/adapters/types.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/blocks.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/diagnoser.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/insights.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/persona_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/subscription.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/analyzer/usage_snapshot.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/doctor.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/fsevents_watch.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/i18n.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/installer/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/installer/login_item.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/installer/session_hooks.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/installer/setup_hook.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/installer/statusline_settings.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/agy_disk_cache.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/agy_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/agy_quota_probe.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/cache_quarantine.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/claude_paths.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/codex_disk_cache.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/codex_events.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/codex_fork_replay.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/codex_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/codex_paths.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/disk_cache_common.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/disk_cache_lifecycle.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/grok_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/grok_quota_probe.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/history_disk_cache.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/history_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/jsonl_limits.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/loaders/jsonl_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/main.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/actions.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/app.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/chrome.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/grok.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/menu.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/notify.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/popover.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/refresh.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/title.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/menubar/update.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/base.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/panel_scale.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/panel_window.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/panel_window_state.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/payload.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/panels/window_drag.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/prefs.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/project_resolver.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/agy_window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/burn_rate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/codex_window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/usage_rate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/quota/window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/service_status.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/setup.cfg +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/setup_app.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_adapters_claude.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_adapters_codex.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_adapters_rate_limits.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_agy_adapter.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_agy_disk_cache.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_agy_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_agy_quota_probe.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_agy_window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_analyzer_blocks.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_analyzer_pipeline.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_burn_rate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_cache_quarantine.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_claude_paths.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_codex_consistency.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_codex_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_codex_window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_diagnoser.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_disk_cache_shards.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_doctor.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_fsevents_watch.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_grok_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_grok_quota_probe.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_history_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_hook_scripts_are_standalone.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_i18n_format_fallback.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_i18n_key_parity.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_i18n_packaged_path.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_insights.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_jsonl_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_login_item.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_main.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_menubar_grok.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_packaged_resources.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_packaging.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_panel_scale.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_panel_window_state.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_panels.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_panels_lazy_import.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_persona_loader.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_pricing.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_project_resolver.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_report_daily_chart.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_report_filter_js.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_service_status.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_session_resume.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_setup_app.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_setup_hook.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_setup_hook_coexistence.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_setup_hook_resume.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_setup_hook_terse.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_subprocess_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_subscription.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_time_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_tui.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_tui_sprite.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_ui_tables.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_update_checker.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_update_gate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_update_release_notes.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_cli.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_client.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_lang.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_logging.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_notifications.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_rate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_snapshot.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_snapshot_replay.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_statusline.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_statusline_agy.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_statusline_grok.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_terse_mode.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_usage_terse_reminder.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_wheel_packaging.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_win_login_item.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_window_keeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tests/test_windows_manifest.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tui/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tui/app.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/tui/sprite.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/report_charts.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/report_scripts.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/ui/report_styles.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/updates/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/updates/checker.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/updates/gate.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/updates/release_notes.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.egg-info/dependency_links.txt +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.egg-info/entry_points.txt +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.egg-info/requires.txt +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_cli.egg-info/top_level.txt +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_client.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/subprocess_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/time_utils.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/usage_lang.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_common/usage_logging.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_notifications.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_session_resume.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_statusline.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_statusline_agy.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_statusline_grok.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_terse_mode.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/usage_terse_reminder.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/wintray/__init__.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/wintray/login_item.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/wintray/menu.py +0 -0
- {usage_cli-0.30.22 → usage_cli-0.30.24}/wintray/watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usage-cli
|
|
3
|
-
Version: 0.30.
|
|
3
|
+
Version: 0.30.24
|
|
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
|
|
@@ -62,8 +62,9 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
62
62
|
### Live Visibility
|
|
63
63
|
|
|
64
64
|
- **Always-on Monitor:** Your quota lives in the menu bar, color-coded from green to red. Click when you want the full session, weekly, and per-project breakdown.
|
|
65
|
-
- **Antigravity Support:** Antigravity (Gemini) session and weekly quota show up as a third card in every theme except World Cup 2026, which stays a two-team HUD. Numbers come straight from the official quota API, using the sign-in the Antigravity CLI already keeps on your machine — refreshed every few minutes, with live reset countdowns.
|
|
65
|
+
- **Antigravity Support:** Antigravity (Gemini) session and weekly quota show up as a third card in every theme except World Cup 2026, which stays a two-team HUD. Numbers come straight from the official quota API, using the sign-in the Antigravity CLI already keeps on your machine — refreshed every few minutes, with live reset countdowns. Antigravity keeps two separate quota pools: the card shows Gemini by default, and tapping the `Gemini ⇄` tag next to the title switches it to Claude / GPT — the choice is remembered.
|
|
66
66
|
- **Grok CLI Support:** A fourth card reads Grok CLI's weekly credit percentage straight from its own local debug log — no network call. Grok CLI doesn't expose session or burn-rate data, so the card shows a single weekly bar; its per-request token usage still counts toward today's cost and project totals like Claude Code and Codex.
|
|
67
|
+
- **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
|
|
67
68
|
- **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages — never an LLM usage API. Antigravity isn't covered; it has no public status page.
|
|
68
69
|
- **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
|
|
69
70
|
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
@@ -204,8 +205,9 @@ If the menu bar shows `--`, it's usually not broken — there's just no local da
|
|
|
204
205
|
| Always on screen | ✅ | — | ✅ |
|
|
205
206
|
| macOS menu bar & Windows system tray | ✅ | — | macOS only |
|
|
206
207
|
| Claude Code & Codex usage | ✅ | Claude only | ✅ |
|
|
207
|
-
| Antigravity (Gemini)
|
|
208
|
+
| Antigravity usage (Gemini and Claude / GPT) | ✅ | — | — |
|
|
208
209
|
| Grok CLI usage | ✅ | — | — |
|
|
210
|
+
| Muse Code token spend | ✅ | — | — |
|
|
209
211
|
| Claude Code & Codex service-status alerts | ✅ | — | — |
|
|
210
212
|
| HTML deep reports & UI | ✅ | ✅ | — |
|
|
211
213
|
| AI Update Daily | ✅ | — | — |
|
|
@@ -42,8 +42,9 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
42
42
|
### Live Visibility
|
|
43
43
|
|
|
44
44
|
- **Always-on Monitor:** Your quota lives in the menu bar, color-coded from green to red. Click when you want the full session, weekly, and per-project breakdown.
|
|
45
|
-
- **Antigravity Support:** Antigravity (Gemini) session and weekly quota show up as a third card in every theme except World Cup 2026, which stays a two-team HUD. Numbers come straight from the official quota API, using the sign-in the Antigravity CLI already keeps on your machine — refreshed every few minutes, with live reset countdowns.
|
|
45
|
+
- **Antigravity Support:** Antigravity (Gemini) session and weekly quota show up as a third card in every theme except World Cup 2026, which stays a two-team HUD. Numbers come straight from the official quota API, using the sign-in the Antigravity CLI already keeps on your machine — refreshed every few minutes, with live reset countdowns. Antigravity keeps two separate quota pools: the card shows Gemini by default, and tapping the `Gemini ⇄` tag next to the title switches it to Claude / GPT — the choice is remembered.
|
|
46
46
|
- **Grok CLI Support:** A fourth card reads Grok CLI's weekly credit percentage straight from its own local debug log — no network call. Grok CLI doesn't expose session or burn-rate data, so the card shows a single weekly bar; its per-request token usage still counts toward today's cost and project totals like Claude Code and Codex.
|
|
47
|
+
- **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
|
|
47
48
|
- **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages — never an LLM usage API. Antigravity isn't covered; it has no public status page.
|
|
48
49
|
- **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
|
|
49
50
|
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
@@ -184,8 +185,9 @@ If the menu bar shows `--`, it's usually not broken — there's just no local da
|
|
|
184
185
|
| Always on screen | ✅ | — | ✅ |
|
|
185
186
|
| macOS menu bar & Windows system tray | ✅ | — | macOS only |
|
|
186
187
|
| Claude Code & Codex usage | ✅ | Claude only | ✅ |
|
|
187
|
-
| Antigravity (Gemini)
|
|
188
|
+
| Antigravity usage (Gemini and Claude / GPT) | ✅ | — | — |
|
|
188
189
|
| Grok CLI usage | ✅ | — | — |
|
|
190
|
+
| Muse Code token spend | ✅ | — | — |
|
|
189
191
|
| Claude Code & Codex service-status alerts | ✅ | — | — |
|
|
190
192
|
| HTML deep reports & UI | ✅ | ✅ | — |
|
|
191
193
|
| AI Update Daily | ✅ | — | — |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from loaders import muse_loader
|
|
7
|
+
from loaders.history_loader import UsageEntry as MuseUsageEntry
|
|
8
|
+
|
|
9
|
+
from i18n import t
|
|
10
|
+
|
|
11
|
+
from .types import AgentInfo, UsageEntry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect() -> AgentInfo | None:
|
|
15
|
+
if muse_loader.session_paths():
|
|
16
|
+
return AgentInfo(
|
|
17
|
+
id="muse",
|
|
18
|
+
name=t("muse_name"),
|
|
19
|
+
data_dir=str(muse_loader.MUSE_SESSIONS_DIR),
|
|
20
|
+
installed=True,
|
|
21
|
+
)
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_entries(hours_back: int = 0) -> list[UsageEntry]:
|
|
26
|
+
return [_to_usage_entry(entry) for entry in muse_loader.load_entries(hours_back)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_usage_entry(entry: MuseUsageEntry) -> UsageEntry:
|
|
30
|
+
return UsageEntry(
|
|
31
|
+
timestamp=entry.timestamp,
|
|
32
|
+
session_id=entry.session_id,
|
|
33
|
+
message_id=entry.message_id,
|
|
34
|
+
request_id=entry.request_id,
|
|
35
|
+
model=entry.model,
|
|
36
|
+
input_tokens=entry.input_tokens,
|
|
37
|
+
output_tokens=entry.output_tokens,
|
|
38
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
39
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
40
|
+
cost_usd=entry.cost_usd,
|
|
41
|
+
project=entry.project,
|
|
42
|
+
agent_id="muse",
|
|
43
|
+
)
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
6
|
|
|
7
7
|
from .types import AgentInfo
|
|
8
|
-
from . import agy, claude, codex, grok
|
|
8
|
+
from . import agy, claude, codex, grok, muse
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def detect_agents() -> list[AgentInfo]:
|
|
12
12
|
agents: list[AgentInfo] = []
|
|
13
|
-
for detector in [claude.detect, codex.detect, agy.detect, grok.detect]:
|
|
13
|
+
for detector in [claude.detect, codex.detect, agy.detect, grok.detect, muse.detect]:
|
|
14
14
|
info = detector()
|
|
15
15
|
if info:
|
|
16
16
|
agents.append(info)
|
|
@@ -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
|
|
@@ -22,16 +22,22 @@ from typing import Any, NotRequired, TypedDict, cast
|
|
|
22
22
|
from loaders import cache_quarantine, codex_loader
|
|
23
23
|
from analyzer import persona_loader
|
|
24
24
|
from analyzer import subscription
|
|
25
|
-
from adapters import agy, claude, codex, grok
|
|
25
|
+
from adapters import agy, claude, codex, grok, muse
|
|
26
26
|
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
|
|
|
34
|
-
AGENT_LOADERS = {
|
|
34
|
+
AGENT_LOADERS = {
|
|
35
|
+
"claude-code": claude,
|
|
36
|
+
"codex": codex,
|
|
37
|
+
"antigravity": agy,
|
|
38
|
+
"grok": grok,
|
|
39
|
+
"muse": muse,
|
|
40
|
+
}
|
|
35
41
|
AGENT_NAMES = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
36
42
|
_YEAR_WEEKS = 53
|
|
37
43
|
YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
|
|
@@ -124,6 +130,7 @@ class TopSessionReportRow(TypedDict):
|
|
|
124
130
|
duration_min: float
|
|
125
131
|
tokens: int
|
|
126
132
|
cost: float
|
|
133
|
+
weekly_quota_pct: float | None
|
|
127
134
|
|
|
128
135
|
|
|
129
136
|
class CubeAgentData(TypedDict):
|
|
@@ -235,6 +242,7 @@ class ReportData(_ReportDataRequired, total=False):
|
|
|
235
242
|
by_agent_model: list[AgentModelReportRow]
|
|
236
243
|
cube: CubeReportData
|
|
237
244
|
sessions: list[SessionRowData]
|
|
245
|
+
dispatch_ledger: dispatch_ledger.LedgerData
|
|
238
246
|
|
|
239
247
|
|
|
240
248
|
@dataclass(frozen=True)
|
|
@@ -905,6 +913,11 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
905
913
|
prev_date_from = date_from - timedelta(days=total_days_for_comparison)
|
|
906
914
|
hours_back = ((date_to - prev_date_from).days + 2) * 24
|
|
907
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
|
+
)
|
|
908
921
|
raw_entries: list[UsageEntry] = []
|
|
909
922
|
for agent in agents:
|
|
910
923
|
raw_entries.extend(_load_agent_entries(agent, hours_back))
|
|
@@ -1187,6 +1200,10 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1187
1200
|
cursor += timedelta(days=1)
|
|
1188
1201
|
|
|
1189
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)
|
|
1190
1207
|
sessions_by_cost = sorted(
|
|
1191
1208
|
aggregate_sessions(entries), key=lambda session: session.cost_usd, reverse=True
|
|
1192
1209
|
)
|
|
@@ -1194,16 +1211,16 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1194
1211
|
sessions_by_cost, key=lambda session: session.total_tokens, reverse=True
|
|
1195
1212
|
)
|
|
1196
1213
|
for session in sessions_by_tokens[:5]:
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
)
|
|
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)
|
|
1207
1224
|
|
|
1208
1225
|
session_rows: list[SessionRowData] = []
|
|
1209
1226
|
for session in sessions_by_cost:
|
|
@@ -1250,6 +1267,7 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1250
1267
|
"sessions": session_rows,
|
|
1251
1268
|
"daily_trend": daily_trend,
|
|
1252
1269
|
"top_sessions": top_sessions,
|
|
1270
|
+
"dispatch_ledger": dispatch_ledger.build_ledger(estimates, now),
|
|
1253
1271
|
"comparison": comparison,
|
|
1254
1272
|
"subscriptions": subscription.load_subscriptions(),
|
|
1255
1273
|
"persona": _load_persona_for_period(period),
|