aiuse 3.2.0__tar.gz → 3.2.2__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 (96) hide show
  1. {aiuse-3.2.0/src/aiuse.egg-info → aiuse-3.2.2}/PKG-INFO +1 -1
  2. {aiuse-3.2.0 → aiuse-3.2.2}/pyproject.toml +1 -1
  3. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/__init__.py +1 -1
  4. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/selfdescribe.py +5 -0
  5. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/report.py +10 -2
  6. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/sampler.py +10 -1
  7. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/watch.py +139 -23
  8. {aiuse-3.2.0 → aiuse-3.2.2/src/aiuse.egg-info}/PKG-INFO +1 -1
  9. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_selfdescribe.py +10 -0
  10. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_watch.py +112 -1
  11. {aiuse-3.2.0 → aiuse-3.2.2}/LICENSE +0 -0
  12. {aiuse-3.2.0 → aiuse-3.2.2}/README.md +0 -0
  13. {aiuse-3.2.0 → aiuse-3.2.2}/setup.cfg +0 -0
  14. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/__main__.py +0 -0
  15. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/agent_notes.py +0 -0
  16. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/ai_stub.py +0 -0
  17. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/__init__.py +0 -0
  18. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/history.py +0 -0
  19. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/local_runtimes.py +0 -0
  20. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/pace.py +0 -0
  21. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/suggest.py +0 -0
  22. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/use_or_lose.py +0 -0
  23. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/attribute.py +0 -0
  24. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/chat_format.py +0 -0
  25. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/cli.py +0 -0
  26. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/__init__.py +0 -0
  27. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/bailian.py +0 -0
  28. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/base.py +0 -0
  29. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/caut.py +0 -0
  30. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/clinepass.py +0 -0
  31. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/codexbar.py +0 -0
  32. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/cswap.py +0 -0
  33. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/grok.py +0 -0
  34. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/hermes.py +0 -0
  35. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/muse.py +0 -0
  36. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/opencode_go.py +0 -0
  37. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/opencode_zen.py +0 -0
  38. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openrouter.py +0 -0
  39. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openusage.py +0 -0
  40. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openusage_sh.py +0 -0
  41. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/qwencloud.py +0 -0
  42. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/runner.py +0 -0
  43. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/tokscale.py +0 -0
  44. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/config.py +0 -0
  45. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/credentials.py +0 -0
  46. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/ledger.py +0 -0
  47. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/macos_trust.py +0 -0
  48. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/models.py +0 -0
  49. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/secretspec.py +0 -0
  50. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/serve.py +0 -0
  51. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tty.py +0 -0
  52. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/__init__.py +0 -0
  53. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/app.py +0 -0
  54. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/builders.py +0 -0
  55. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/SOURCES.txt +0 -0
  56. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/dependency_links.txt +0 -0
  57. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/entry_points.txt +0 -0
  58. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/requires.txt +0 -0
  59. {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/top_level.txt +0 -0
  60. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_attribution.py +0 -0
  61. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_bailian.py +0 -0
  62. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_base_run_json.py +0 -0
  63. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_caut_parse.py +0 -0
  64. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_chat_format.py +0 -0
  65. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_cli.py +0 -0
  66. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_clinepass.py +0 -0
  67. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_codexbar_parse.py +0 -0
  68. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_config.py +0 -0
  69. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_credentials.py +0 -0
  70. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_cswap_parse.py +0 -0
  71. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_grok.py +0 -0
  72. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_hermes_parse.py +0 -0
  73. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_history.py +0 -0
  74. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_install_deps.py +0 -0
  75. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_local_runtimes.py +0 -0
  76. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_macos_trust.py +0 -0
  77. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_models_cli_binary.py +0 -0
  78. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_models_pace.py +0 -0
  79. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_muse.py +0 -0
  80. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_opencode_go.py +0 -0
  81. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_opencode_zen.py +0 -0
  82. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_openrouter.py +0 -0
  83. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_openusage_parse.py +0 -0
  84. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_pace.py +0 -0
  85. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_qwencloud.py +0 -0
  86. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_release_script.py +0 -0
  87. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_report.py +0 -0
  88. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_runner_consolidation.py +0 -0
  89. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_serve.py +0 -0
  90. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_shared_quota_semantics.py +0 -0
  91. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_snapshots_persistence.py +0 -0
  92. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_suggest.py +0 -0
  93. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tokscale_parse.py +0 -0
  94. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tty.py +0 -0
  95. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tui.py +0 -0
  96. {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_use_or_lose.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
5
5
  Author: djbclark
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.0"
7
+ version = "3.2.2"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.2.0"
3
+ __version__ = "3.2.2"
@@ -317,6 +317,11 @@ def summary_line(pool: Mapping[str, Any]) -> str:
317
317
  provider = str(pool.get("provider") or "?")
318
318
  family = pool.get("pool_family")
319
319
  lead = f"{provider} {family}" if family else provider
320
+ # The command you actually type, when it is not the provider id: `cursor`
321
+ # opens the editor (the agent is cursor-agent), antigravity is `agy`.
322
+ binary = pool.get("cli_binary")
323
+ if binary and str(binary) != provider:
324
+ lead += f" [{binary}]"
320
325
  segments: list[str] = []
321
326
  for window in pool.get("windows") or []:
322
327
  label = str(window.get("label") or "?")
@@ -280,7 +280,7 @@ def render_report(
280
280
  lines.append(s.bold("=" * width))
281
281
  title = "AI USAGE — USE IT OR LOSE IT (full)"
282
282
  lines.append(s.bold(s.cyan(title)))
283
- meta = f"Collected at {snapshot.collected_at.isoformat()}"
283
+ meta = f"Collected at {format_clock(snapshot.collected_at, date=True)}"
284
284
  meta += f" · {n_accounts} account{'s' if n_accounts != 1 else ''}"
285
285
  if n_actionable:
286
286
  meta += f" · {n_actionable} alert{'s' if n_actionable != 1 else ''}"
@@ -1703,6 +1703,14 @@ def _priority_account_line(
1703
1703
  return f"{_priority_tag(s, band)} {body}"
1704
1704
 
1705
1705
 
1706
+ def format_clock(when: datetime, *, seconds: bool = False, date: bool = False) -> str:
1707
+ """Local 12-hour time as people say it: ``9:02pm``, ``9:02:07pm``, ``Sat Oct 5 9:02pm``."""
1708
+ local = when.astimezone()
1709
+ clock = f"{local.hour % 12 or 12}:{local:%M}" + (f":{local:%S}" if seconds else "")
1710
+ clock += "am" if local.hour < 12 else "pm"
1711
+ return f"{local:%a %b} {local.day} {clock}" if date else clock
1712
+
1713
+
1706
1714
  def render_stderr_meta(
1707
1715
  snapshot: Snapshot,
1708
1716
  alerts: list[UseOrLoseAlert],
@@ -1715,7 +1723,7 @@ def render_stderr_meta(
1715
1723
  n_accounts = len(accounts)
1716
1724
  n_actionable = sum(1 for a in alerts if a.urgency not in (Urgency.INFO, Urgency.NONE))
1717
1725
  lines: list[str] = []
1718
- meta = f"Collected at {snapshot.collected_at.isoformat()}"
1726
+ meta = f"Collected at {format_clock(snapshot.collected_at, date=True)}"
1719
1727
  meta += f" · {n_accounts} account{'s' if n_accounts != 1 else ''}"
1720
1728
  if n_actionable:
1721
1729
  meta += f" · {n_actionable} alert{'s' if n_actionable != 1 else ''}"
@@ -24,7 +24,7 @@ import os
24
24
  import sys
25
25
  from concurrent.futures import Future, ThreadPoolExecutor
26
26
  from dataclasses import dataclass, field
27
- from datetime import datetime, timezone
27
+ from datetime import datetime, timedelta, timezone
28
28
  from pathlib import Path
29
29
  from typing import Any, Callable
30
30
 
@@ -109,6 +109,15 @@ def decide(state: dict[str, Any], settings: dict[str, float], now: datetime, *,
109
109
  return Decision("full", f"{tier} tier")
110
110
 
111
111
 
112
+ def schedule(state: dict[str, Any], settings: dict[str, float]) -> tuple[datetime, datetime, str] | None:
113
+ """``(previous sample, next sample due, tier)``, or None before the first sample."""
114
+ last = _parse(state.get("last_sample_at"))
115
+ if last is None:
116
+ return None
117
+ tier = state.get("tier") if state.get("tier") in TIERS else "idle"
118
+ return last, last + timedelta(seconds=settings[f"{tier}_interval"]), str(tier)
119
+
120
+
112
121
  def _series(snapshot: Snapshot) -> dict[str, dict[str, Any]]:
113
122
  out: dict[str, dict[str, Any]] = {}
114
123
  for account in snapshot.accounts:
@@ -20,7 +20,7 @@ from aiuse.analysis.local_runtimes import maybe_local_runtime_alerts
20
20
  from aiuse.analysis.use_or_lose import analyze_use_or_lose
21
21
  from aiuse.collectors.runner import run_collectors
22
22
  from aiuse.models import Snapshot, UseOrLoseAlert, utcnow
23
- from aiuse.report import render_clock_matrix, render_stderr_meta
23
+ from aiuse.report import _strip_ansi, format_clock, render_clock_matrix, render_stderr_meta
24
24
 
25
25
  DEFAULT_INTERVAL_S = 600.0
26
26
  MIN_INTERVAL_S = 5.0
@@ -57,25 +57,115 @@ def parse_interval(value: str | float | int) -> float:
57
57
  return seconds
58
58
 
59
59
 
60
- def collect_watch_frame(config: dict[str, Any]) -> tuple[Snapshot, list[UseOrLoseAlert]]:
61
- """One live collect + analysis pass (same as a normal CLI run)."""
62
- snapshot = run_collectors(config)
63
- alerts = analyze_use_or_lose(snapshot, config)
64
- alerts.extend(maybe_local_runtime_alerts(snapshot, config=config))
60
+ def _overlay_burst_samples(snapshot: Snapshot) -> bool:
61
+ """Replace accounts with any newer burst-tier reading. True if one applied.
62
+
63
+ During a burst the sampler reads the burning providers every few minutes
64
+ into partial samples, while full snapshots stay 15 minutes apart. The
65
+ board is most useful exactly then, so it shows the newer reading for those
66
+ providers and the full snapshot for everything else.
67
+ """
68
+ from aiuse.analysis.history import account_key, partial_sample_dir
69
+ from aiuse.ledger import load_json_range
70
+ from aiuse.models import canonical_provider
71
+ from aiuse.serve import _snapshot_from_accounts_dict
72
+
73
+ applied = False
74
+ for row in load_json_range(partial_sample_dir(), snapshot.collected_at, None):
75
+ for fresh in _snapshot_from_accounts_dict(row).accounts:
76
+ if fresh.error or not fresh.windows:
77
+ continue
78
+ provider = canonical_provider(fresh.provider)
79
+ same = [i for i, a in enumerate(snapshot.accounts) if canonical_provider(a.provider) == provider]
80
+ exact = [i for i in same if account_key(snapshot.accounts[i].account) == account_key(fresh.account)]
81
+ # A provider-scoped reading belongs to the provider's only account.
82
+ target = exact[0] if exact else same[0] if len(same) == 1 else None
83
+ if target is None:
84
+ continue
85
+ fresh.account = fresh.account or snapshot.accounts[target].account
86
+ snapshot.accounts[target] = fresh
87
+ applied = True
88
+ return applied
89
+
90
+
91
+ def _frame_from_disk(max_age: float, config: dict[str, Any]) -> tuple[Snapshot, list[UseOrLoseAlert]] | None:
92
+ """The newest full snapshot on disk, if it is younger than ``max_age`` seconds."""
93
+ from aiuse.analysis.history import load_recent_snapshots
94
+ from aiuse.serve import _alerts_from_dicts, _disk_row_age_ok, _snapshot_from_accounts_dict
95
+
96
+ rows = load_recent_snapshots(max_count=1)
97
+ if not rows or not _disk_row_age_ok(rows[0], max_age):
98
+ return None
99
+ snapshot = _snapshot_from_accounts_dict(rows[0])
100
+ if _overlay_burst_samples(snapshot):
101
+ # The stored alerts describe the older readings; re-derive them.
102
+ alerts = analyze_use_or_lose(snapshot, config)
103
+ alerts.extend(maybe_local_runtime_alerts(snapshot, config=config))
104
+ return snapshot, alerts
105
+ return snapshot, _alerts_from_dicts(rows[0].get("alerts") or [])
106
+
107
+
108
+ def collect_watch_frame(
109
+ config: dict[str, Any], *, max_age: float | None = None
110
+ ) -> tuple[Snapshot, list[UseOrLoseAlert]]:
111
+ """One frame for the board, sharing one polling pipeline with ``aiuse sample``.
112
+
113
+ With ``max_age`` set, a snapshot the scheduled sampler (or anything else)
114
+ wrote within that many seconds is shown as is: the board and the sampler
115
+ are not two clocks polling the same vendors. Otherwise this collects, and
116
+ records the result the way a sample does — snapshot, token ledger, sampler
117
+ state — so the scheduled job sees a fresh sample and skips its own.
118
+ """
65
119
  raw_analysis = config.get("analysis")
66
120
  analysis_cfg: dict[str, Any] = raw_analysis if isinstance(raw_analysis, dict) else {}
67
- if should_persist_snapshots(analysis_cfg):
121
+ persist = should_persist_snapshots(analysis_cfg)
122
+ if max_age and persist:
123
+ cached = _frame_from_disk(max_age, config)
124
+ if cached is not None:
125
+ return cached
126
+
127
+ from aiuse import sampler
128
+ from aiuse.ledger import save_ledger
129
+
130
+ ledger_sample = None
131
+ if persist:
132
+ snapshot, ledger_sample, _ledger_error = sampler.collect_with_ledger(config, run_collectors)
133
+ else:
134
+ snapshot = run_collectors(config)
135
+ alerts = analyze_use_or_lose(snapshot, config)
136
+ alerts.extend(maybe_local_runtime_alerts(snapshot, config=config))
137
+ if persist:
138
+ retention = int(analysis_cfg.get("snapshot_retention_days") or 90)
68
139
  try:
69
- save_snapshot(
70
- snapshot,
71
- alerts,
72
- retention_days=int(analysis_cfg.get("snapshot_retention_days") or 90),
73
- )
140
+ save_snapshot(snapshot, alerts, retention_days=retention)
141
+ if ledger_sample is not None:
142
+ save_ledger(ledger_sample, collected_at=snapshot.collected_at, retention_days=retention)
143
+ settings = sampler.sampling_settings(config)
144
+ sampler.save_state(sampler.advance_state(sampler.load_state(), snapshot, settings, partial=False))
74
145
  except OSError:
75
146
  pass
76
147
  return snapshot, alerts
77
148
 
78
149
 
150
+ _schedule_cache: tuple[float, tuple[datetime, datetime, str] | None] | None = None
151
+
152
+
153
+ def _sample_schedule(config: dict[str, Any]) -> tuple[datetime, datetime, str] | None:
154
+ """When ``aiuse sample`` last ran and is next due. Re-read every 2 seconds:
155
+ the board redraws four times a second and the state file changes rarely."""
156
+ global _schedule_cache
157
+ from aiuse import sampler
158
+
159
+ if _schedule_cache is not None and time.monotonic() - _schedule_cache[0] < 2.0:
160
+ return _schedule_cache[1]
161
+ try:
162
+ value = sampler.schedule(sampler.load_state(), sampler.sampling_settings(config))
163
+ except Exception: # noqa: BLE001 — a header detail must never take the board down
164
+ value = None
165
+ _schedule_cache = (time.monotonic(), value)
166
+ return value
167
+
168
+
79
169
  def render_watch_board(
80
170
  snapshot: Snapshot | None,
81
171
  alerts: list[UseOrLoseAlert],
@@ -87,11 +177,21 @@ def render_watch_board(
87
177
  next_in: float | None = None,
88
178
  collecting_for: float | None = None,
89
179
  error: str | None = None,
180
+ now: datetime | None = None,
181
+ sample_schedule: tuple[datetime, datetime, str] | None = None,
90
182
  ) -> str:
91
- """Header + clock matrix + optional footer for the alternate-screen board."""
183
+ """Header + clock matrix + optional footer for the alternate-screen board.
184
+
185
+ ``last`` is when the data on the board was collected, which is not when
186
+ the board last refreshed: a frame can come from a snapshot the scheduled
187
+ sampler wrote some minutes ago.
188
+ """
92
189
  header_bits = ["aiuse watch"]
93
- if last_at is not None:
94
- header_bits.append(f"last: {last_at.astimezone().strftime('%H:%M:%S')}")
190
+ if now is not None:
191
+ header_bits.append(f"now: {format_clock(now, seconds=True)}")
192
+ data_at = snapshot.collected_at if snapshot is not None else last_at
193
+ if data_at is not None:
194
+ header_bits.append(f"last: {format_clock(data_at, seconds=True)}")
95
195
  else:
96
196
  header_bits.append("last: —")
97
197
  if collecting_for is not None:
@@ -101,14 +201,25 @@ def render_watch_board(
101
201
  header_bits.append(f"next in {mins}:{secs:02d}")
102
202
  header_bits.append("q/esc quit")
103
203
  lines = [" · ".join(header_bits)]
204
+ if sample_schedule is not None:
205
+ previous, due, tier = sample_schedule
206
+ overdue = " (due)" if now is not None and due <= now else ""
207
+ lines.append(
208
+ f"sampler: previous {format_clock(previous, seconds=True)}"
209
+ f" · next {format_clock(due, seconds=True)}{overdue} · {tier} tier"
210
+ )
104
211
  if error:
105
212
  lines.append(f"collect error: {error}")
106
213
  if snapshot is not None:
107
- lines.append(render_clock_matrix(alerts, snapshot=snapshot, config=config, color=color).rstrip())
214
+ matrix = render_clock_matrix(alerts, snapshot=snapshot, config=config, color=color).rstrip()
215
+ lines.append(matrix)
108
216
  if not quiet:
109
217
  footer = render_stderr_meta(snapshot, alerts, color=color).rstrip()
110
218
  if footer:
111
- lines.append(footer)
219
+ # Centered under the table, like the legend lines above it.
220
+ width = max((len(_strip_ansi(row)) for row in matrix.splitlines()), default=0)
221
+ for row in footer.splitlines():
222
+ lines.append(" " * max(0, (width - len(_strip_ansi(row))) // 2) + row)
112
223
  else:
113
224
  lines.append("waiting for first collection…")
114
225
  return "\n".join(lines)
@@ -223,7 +334,7 @@ class WatchRuntime:
223
334
  return max(0.0, self.next_due - self.now())
224
335
 
225
336
 
226
- def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
337
+ def _collect_process_entry(config: dict[str, Any], send: Any, max_age: float | None = None) -> None:
227
338
  """Collect in an isolated process so an in-flight refresh is cancellable."""
228
339
  if os.name == "posix":
229
340
  try:
@@ -231,7 +342,7 @@ def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
231
342
  except OSError:
232
343
  pass
233
344
  try:
234
- snapshot, alerts = collect_watch_frame(config)
345
+ snapshot, alerts = collect_watch_frame(config, max_age=max_age)
235
346
  send.send(("ok", snapshot, alerts))
236
347
  except BaseException as exc: # noqa: BLE001 — return a board error instead of losing the worker
237
348
  try:
@@ -245,8 +356,9 @@ def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
245
356
  class _WatchCollectionProcess:
246
357
  """One cancellable live-collection process for interactive watch mode."""
247
358
 
248
- def __init__(self, config: dict[str, Any]) -> None:
359
+ def __init__(self, config: dict[str, Any], max_age: float | None = None) -> None:
249
360
  self.config = config
361
+ self.max_age = max_age
250
362
  methods = multiprocessing.get_all_start_methods()
251
363
  self.context: Any = multiprocessing.get_context("fork" if "fork" in methods else "spawn")
252
364
  self.process: multiprocessing.Process | None = None
@@ -256,7 +368,7 @@ class _WatchCollectionProcess:
256
368
  recv, send = self.context.Pipe(duplex=False)
257
369
  process = self.context.Process(
258
370
  target=_collect_process_entry,
259
- args=(self.config, send),
371
+ args=(self.config, send, self.max_age),
260
372
  name="aiuse-watch-collect",
261
373
  daemon=True,
262
374
  )
@@ -337,7 +449,7 @@ def run_watch(
337
449
  out = stdout if stdout is not None else sys.stdout
338
450
  err = stderr if stderr is not None else sys.stderr
339
451
  color = False if no_color else None
340
- collect_fn = collect or (lambda: collect_watch_frame(config))
452
+ collect_fn = collect or (lambda: collect_watch_frame(config, max_age=interval))
341
453
  runtime = WatchRuntime(interval=interval, collect=collect_fn, now=now or time.monotonic)
342
454
 
343
455
  if once:
@@ -353,6 +465,8 @@ def run_watch(
353
465
  next_in=None,
354
466
  collecting_for=None,
355
467
  error=runtime.error,
468
+ now=utcnow(),
469
+ sample_schedule=_sample_schedule(config),
356
470
  ),
357
471
  file=out,
358
472
  )
@@ -384,7 +498,7 @@ def run_watch(
384
498
  )
385
499
  reader = key_reader or StdinKeyReader()
386
500
  stop = threading.Event()
387
- process_worker = _WatchCollectionProcess(config) if collect is None else None
501
+ process_worker = _WatchCollectionProcess(config, max_age=interval) if collect is None else None
388
502
 
389
503
  def start_worker(fn: Callable[[], None]) -> None:
390
504
  if process_worker is not None:
@@ -423,6 +537,8 @@ def run_watch(
423
537
  next_in=runtime.next_in(),
424
538
  collecting_for=runtime.collecting_for(),
425
539
  error=runtime.error,
540
+ now=utcnow(),
541
+ sample_schedule=_sample_schedule(config),
426
542
  )
427
543
  )
428
544
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
5
5
  Author: djbclark
6
6
  License-Expression: MIT
@@ -435,3 +435,13 @@ def test_plain_output_never_bare_percentage():
435
435
  for match in bare.finditer(line):
436
436
  tail = line[match.start() :]
437
437
  assert labeled.search(tail), f"bare percentage {match.group()!r} in line: {line!r}"
438
+
439
+
440
+ def test_summary_line_names_the_binary_when_it_differs_from_the_provider_id():
441
+ from aiuse.analysis.selfdescribe import summary_line
442
+
443
+ window = {"label": "Cursor Auto", "state": "ok", "used_percent": 1.0, "headroom_percent": 99.0}
444
+ cursor = {"provider": "cursor", "pool_family": "auto", "cli_binary": "cursor-agent", "windows": [window]}
445
+ assert summary_line(cursor).startswith("cursor auto [cursor-agent]: Cursor Auto: ok")
446
+ codex = {"provider": "codex", "pool_family": None, "cli_binary": "codex", "windows": [window]}
447
+ assert summary_line(codex).startswith("codex: ")
@@ -205,7 +205,7 @@ def test_run_watch_stops_default_collection_process_on_quit(monkeypatch):
205
205
  instances = []
206
206
 
207
207
  class FakeProcessWorker:
208
- def __init__(self, config):
208
+ def __init__(self, config, max_age=None):
209
209
  self.config = config
210
210
  self.started = False
211
211
  self.stop_calls = 0
@@ -307,3 +307,114 @@ def test_collect_watch_frame_persists_when_enabled(monkeypatch):
307
307
  monkeypatch.setattr("aiuse.watch.save_snapshot", lambda *a, **k: saved.append(True) or "/tmp/x")
308
308
  collect_watch_frame({"analysis": {"persist_snapshots": True}})
309
309
  assert saved == [True]
310
+
311
+
312
+ def test_watch_frame_reuses_a_fresh_snapshot_instead_of_collecting(monkeypatch):
313
+ """The board rides the scheduled sampler's data rather than polling beside it."""
314
+ from aiuse.analysis.history import save_snapshot
315
+
316
+ save_snapshot(_snap(), [])
317
+ monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: pytest.fail("must not collect"))
318
+ snapshot, alerts = collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
319
+ assert [a.provider for a in snapshot.accounts] == [a.provider for a in _snap().accounts]
320
+ assert alerts == []
321
+
322
+
323
+ def test_watch_frame_collects_when_the_snapshot_is_stale_and_records_a_sample(monkeypatch):
324
+ from datetime import timedelta
325
+
326
+ from aiuse import sampler
327
+ from aiuse.analysis.history import load_recent_snapshots, save_snapshot
328
+
329
+ old = _snap()
330
+ old.collected_at -= timedelta(hours=2)
331
+ save_snapshot(old, [])
332
+ calls: list[int] = []
333
+ monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: calls.append(1) or _snap())
334
+ monkeypatch.setattr("aiuse.watch.analyze_use_or_lose", lambda *_a, **_k: [])
335
+ monkeypatch.setattr("aiuse.watch.maybe_local_runtime_alerts", lambda *_a, **_k: [])
336
+ collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
337
+ assert calls == [1] and len(load_recent_snapshots(retention_days=10_000)) == 2
338
+ # The scheduled job now sees a sample this recent and skips its own.
339
+ state = sampler.load_state()
340
+ assert sampler.decide(state, sampler.sampling_settings(None), _snap().collected_at).action == "skip"
341
+
342
+
343
+ def test_board_header_shows_now_data_time_and_the_sampler_schedule():
344
+ from datetime import datetime, timedelta, timezone
345
+
346
+ from aiuse import sampler
347
+ from aiuse.report import format_clock
348
+ from aiuse.watch import render_watch_board
349
+
350
+ snap = _snap()
351
+ snap.collected_at = datetime(2026, 10, 3, 12, 0, 5, tzinfo=timezone.utc)
352
+ now = snap.collected_at + timedelta(minutes=7)
353
+ state = {"tier": "active", "last_sample_at": snap.collected_at.isoformat()}
354
+ schedule = sampler.schedule(state, sampler.sampling_settings(None))
355
+ assert schedule is not None and schedule[1] == snap.collected_at + timedelta(minutes=15)
356
+ board = render_watch_board(snap, [], color=False, quiet=True, now=now, sample_schedule=schedule)
357
+ header, sampler_line = board.splitlines()[:2]
358
+
359
+ def hms(value):
360
+ return format_clock(value, seconds=True)
361
+
362
+ assert f"now: {hms(now)}" in header and f"last: {hms(snap.collected_at)}" in header
363
+ assert sampler_line == f"sampler: previous {hms(snap.collected_at)} · next {hms(schedule[1])} · active tier"
364
+ late = render_watch_board(snap, [], color=False, quiet=True, now=now + timedelta(hours=1), sample_schedule=schedule)
365
+ assert "(due)" in late.splitlines()[1]
366
+ assert sampler.schedule({}, sampler.sampling_settings(None)) is None
367
+
368
+
369
+ def test_watch_frame_overlays_newer_burst_samples_on_the_full_snapshot(monkeypatch):
370
+ from datetime import timedelta
371
+
372
+ from aiuse.analysis.history import save_snapshot
373
+
374
+ def account(provider: str, used: float, source: str = "codexbar") -> AccountUsage:
375
+ window = QuotaWindow(label=f"{provider} weekly", used_percent=used, window_minutes=10080)
376
+ return AccountUsage(
377
+ source=source, provider=provider, billing_kind=BillingKind.SUBSCRIPTION_WINDOW, windows=[window]
378
+ )
379
+
380
+ full_at = utcnow() - timedelta(minutes=5)
381
+ save_snapshot(Snapshot(collected_at=full_at, accounts=[account("clinepass", 40), account("codex", 10)]), [])
382
+ for minutes, used in ((2, 44), (4, 47)):
383
+ burst = Snapshot(collected_at=full_at + timedelta(minutes=minutes), accounts=[account("clinepass", used)])
384
+ save_snapshot(burst, [], partial_providers=["clinepass"])
385
+ # A burst reading older than the full snapshot must not roll it back.
386
+ stale = Snapshot(collected_at=full_at - timedelta(minutes=3), accounts=[account("codex", 1)])
387
+ save_snapshot(stale, [], partial_providers=["codex"])
388
+
389
+ monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: pytest.fail("must not collect"))
390
+ seen: list[Snapshot] = []
391
+ monkeypatch.setattr("aiuse.watch.analyze_use_or_lose", lambda snap, _c: seen.append(snap) or [])
392
+ monkeypatch.setattr("aiuse.watch.maybe_local_runtime_alerts", lambda *_a, **_k: [])
393
+ snapshot, _alerts = collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
394
+ used = {a.provider: a.windows[0].used_percent for a in snapshot.accounts}
395
+ assert used == {"clinepass": 47, "codex": 10}
396
+ assert seen == [snapshot], "alerts are re-derived from the overlaid readings"
397
+
398
+
399
+ def test_format_clock_is_twelve_hour_without_leading_zeros():
400
+ from datetime import datetime
401
+
402
+ from aiuse.report import format_clock
403
+
404
+ evening = datetime(2026, 10, 3, 21, 2, 7).astimezone()
405
+ assert format_clock(evening) == "9:02pm"
406
+ assert format_clock(evening, seconds=True) == "9:02:07pm"
407
+ assert format_clock(evening, date=True) == "Sat Oct 3 9:02pm"
408
+ assert format_clock(datetime(2026, 10, 3, 0, 5).astimezone()) == "12:05am"
409
+ assert format_clock(datetime(2026, 10, 3, 12, 0).astimezone()) == "12:00pm"
410
+
411
+
412
+ def test_board_footer_is_centered_under_the_table():
413
+ from aiuse.watch import render_watch_board
414
+
415
+ rows = render_watch_board(_snap(), [], color=False).splitlines()
416
+ width = max(len(r) for r in rows[: rows.index(next(r for r in rows if "Collected at" in r))] if "watch" not in r)
417
+ meta = next(r for r in rows if "Collected at" in r)
418
+ left = len(meta) - len(meta.lstrip())
419
+ assert left == max(0, (width - len(meta.strip())) // 2)
420
+ assert "am ·" in meta or "pm ·" in meta
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes