usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
window_keeper.py ADDED
@@ -0,0 +1,264 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ """Auto-open the next Claude 5-hour window.
8
+
9
+ When the user's Claude quota window has expired (``current_reset_at < now``) and
10
+ no window is currently running, this fires a single ``claude -p ok --model haiku``
11
+ message in the background to start a fresh window. The ping is the cheapest
12
+ possible Claude call; it does NOT touch any Anthropic quota API — it only shells
13
+ out to the user's local ``claude`` CLI.
14
+
15
+ Known-unreliable, cause unidentified: on 2026-08-12 two pings ran with
16
+ indistinguishable requests (same version, entrypoint, model, token shape) and
17
+ only one opened a window. The 06:33 ping lines up with an 11:30 reset; the 17:00
18
+ one did not open anything — the next window began only when the user typed into
19
+ Claude Code at 18:30. The user confirmed no claude.ai/mobile activity that
20
+ morning, so the 06:33 window is not explained away by another device. Not a
21
+ model issue (the docs say plan windows are shared across all models).
22
+
23
+ That ping runs headless, so Claude Code's statusLine hook never fires and
24
+ ``usage-status.json`` keeps reporting the boundary we already handled. A
25
+ cooldown clock therefore re-arms the keeper independently of the payload, so a
26
+ machine that never opens Claude Code interactively still gets a fresh window
27
+ every cycle instead of wedging after the first ping.
28
+
29
+ Defaults OFF. All judgement and side effects live here; ``menubar.py`` only
30
+ dispatches a one-line call into :func:`maybe_ping`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import os
38
+ import shutil
39
+ import subprocess
40
+ import tempfile
41
+ import threading
42
+ import time
43
+ from contextlib import suppress
44
+ from pathlib import Path
45
+
46
+ from menubar_prefs import _window_keeper_enabled
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # State file holding the last handled reset boundary and dispatch time. Module
51
+ # constant so tests can monkeypatch it instead of touching the real
52
+ # ``~/.usage/`` dir.
53
+ WINDOW_KEEPER_STATE_PATH = Path(os.path.expanduser("~/.usage/window_keeper.json"))
54
+
55
+ PING_TIMEOUT_SECONDS = 180
56
+
57
+ # One full window plus five minutes, so a re-arm can never land inside the
58
+ # window the previous ping opened.
59
+ PING_COOLDOWN_SECONDS = 5 * 3600 + 300
60
+
61
+ # usage_client defaults a missing ``resets_at`` to parse-time "now", which one
62
+ # refresh later reads as "expired seconds ago". Requiring the expiry to be at
63
+ # least this old filters those synthetic timestamps without delaying a real
64
+ # expired-while-away ping by more than two minutes.
65
+ PING_EXPIRY_GRACE_SECONDS = 120
66
+
67
+ # ``claude`` binary resolution order: PATH first, then the well-known install
68
+ # spots the Claude Code installer/Node/Homebrew lay down. The .app bundle runs
69
+ # with a minimal PATH, so the native installer's ~/.local/bin must be listed.
70
+ _CLAUDE_BIN_FALLBACKS = (
71
+ "~/.local/bin/claude",
72
+ "~/.local/bin/claude.exe",
73
+ "~/.claude/local/claude",
74
+ "/opt/homebrew/bin/claude",
75
+ "/usr/local/bin/claude",
76
+ )
77
+
78
+ _lock = threading.Lock()
79
+ _ping_in_flight = False
80
+
81
+
82
+ def should_ping(
83
+ now: float,
84
+ current_reset_at: float | None,
85
+ enabled: bool,
86
+ last_pinged_reset_at: float | None,
87
+ last_ping_at: float | None,
88
+ current_percent: float | None,
89
+ data_source: str,
90
+ ) -> bool:
91
+ """Pure gate — no I/O. See module docstring for the rules."""
92
+ if not enabled:
93
+ return False
94
+ # Only the statusLine hook carries trustworthy reset timestamps; fallback
95
+ # sources (tt-fallback / claude-json) may default a missing resets_at to
96
+ # parse time, which would read as "expired" and fire a spurious ping.
97
+ if data_source != "hook":
98
+ return False
99
+ # No five-hour block in the payload at all — we can't tell whether a window
100
+ # is running, so stay quiet rather than risk a false start.
101
+ if current_percent is None:
102
+ return False
103
+ if current_reset_at is None or now - current_reset_at < PING_EXPIRY_GRACE_SECONDS:
104
+ return False
105
+ # Two independent re-arm paths, because the two cases run on different
106
+ # clocks. A different real boundary means the user was active and the
107
+ # window genuinely rolled over — fire regardless of how recent the last
108
+ # ping was. The same boundary reported over and over means the payload has
109
+ # gone stale (see module docstring), and only the dispatch clock can tell
110
+ # us another window's worth of time has passed.
111
+ if current_reset_at != last_pinged_reset_at:
112
+ return True
113
+ return last_ping_at is None or now - last_ping_at >= PING_COOLDOWN_SECONDS
114
+
115
+
116
+ def _load_ping_state(
117
+ path: Path | None = None,
118
+ ) -> tuple[float | None, float | None]:
119
+ state_path = WINDOW_KEEPER_STATE_PATH if path is None else path
120
+ if not state_path.exists():
121
+ return None, None
122
+ try:
123
+ data = json.loads(state_path.read_text(encoding="utf-8"))
124
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
125
+ return None, None
126
+ if not isinstance(data, dict):
127
+ return None, None
128
+
129
+ def numeric_value(key: str) -> float | None:
130
+ value = data.get(key)
131
+ if isinstance(value, bool) or not isinstance(value, int | float):
132
+ return None
133
+ return float(value)
134
+
135
+ return numeric_value("last_pinged_reset_at"), numeric_value("last_ping_at")
136
+
137
+
138
+ def _save_ping_state(
139
+ reset_at: float,
140
+ ping_at: float,
141
+ path: Path | None = None,
142
+ ) -> None:
143
+ state_path = WINDOW_KEEPER_STATE_PATH if path is None else path
144
+ state_path.parent.mkdir(parents=True, exist_ok=True)
145
+ payload = json.dumps(
146
+ {"last_pinged_reset_at": reset_at, "last_ping_at": ping_at}
147
+ ) + "\n"
148
+ tmp_path: str | None = None
149
+ try:
150
+ fd, tmp_path = tempfile.mkstemp(dir=state_path.parent, suffix=".tmp")
151
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
152
+ handle.write(payload)
153
+ os.replace(tmp_path, state_path)
154
+ tmp_path = None
155
+ except OSError:
156
+ _debug_log("window-keeper state write failed", exc_info=True)
157
+ finally:
158
+ if tmp_path and os.path.exists(tmp_path):
159
+ with suppress(OSError):
160
+ os.unlink(tmp_path)
161
+
162
+
163
+ def _resolve_claude_bin() -> str | None:
164
+ found = shutil.which("claude")
165
+ if found:
166
+ return found
167
+ for candidate in _CLAUDE_BIN_FALLBACKS:
168
+ resolved = os.path.expanduser(candidate)
169
+ if os.path.isfile(resolved) and os.access(resolved, os.X_OK):
170
+ return resolved
171
+ return None
172
+
173
+
174
+ def _try_acquire() -> bool:
175
+ global _ping_in_flight
176
+ with _lock:
177
+ if _ping_in_flight:
178
+ return False
179
+ _ping_in_flight = True
180
+ return True
181
+
182
+
183
+ def _release() -> None:
184
+ global _ping_in_flight
185
+ with _lock:
186
+ _ping_in_flight = False
187
+
188
+
189
+ def _run_claude_ping(claude_bin: str) -> None:
190
+ # encoding="utf-8" is mandatory inside the .app bundle (project invariant).
191
+ subprocess.run( # noqa: S603 - shelling out to the user's own claude CLI by resolved path
192
+ [claude_bin, "-p", "ok", "--model", "haiku"],
193
+ stdin=subprocess.DEVNULL,
194
+ capture_output=True,
195
+ encoding="utf-8",
196
+ errors="replace",
197
+ timeout=PING_TIMEOUT_SECONDS,
198
+ cwd=os.path.expanduser("~"),
199
+ check=False,
200
+ )
201
+
202
+
203
+ def _ping_worker(started_at: float) -> None:
204
+ try:
205
+ claude_bin = _resolve_claude_bin()
206
+ if claude_bin is None:
207
+ _debug_log("window-keeper: claude binary not found, skipping ping")
208
+ return
209
+ _run_claude_ping(claude_bin)
210
+ _debug_log(f"window-keeper: ping completed (started_at={started_at})")
211
+ except subprocess.TimeoutExpired:
212
+ _debug_log(f"window-keeper: ping timed out after {PING_TIMEOUT_SECONDS}s")
213
+ except Exception:
214
+ # Never let a ping failure escape into the app — this runs on a daemon
215
+ # thread whose exception would otherwise be silently swallowed anyway,
216
+ # but be explicit so a future refactor can't crash the main loop.
217
+ _debug_log("window-keeper: ping failed", exc_info=True)
218
+ finally:
219
+ _release()
220
+
221
+
222
+ def maybe_ping(
223
+ current_reset_at: float | None,
224
+ current_percent: float | None,
225
+ data_source: str,
226
+ mock: bool,
227
+ ) -> None:
228
+ """High-level entry: read prefs + state, gate, and fire a background ping.
229
+
230
+ Returns immediately — the subprocess runs on a daemon thread. Safe to call
231
+ on every UI refresh; boundary deduplication + the in-flight guard make it a
232
+ no-op when busy.
233
+ """
234
+ if mock:
235
+ return
236
+ enabled = _window_keeper_enabled()
237
+ if not enabled:
238
+ # Switch off → zero side effects: don't read or write state, don't spawn.
239
+ return
240
+ now = time.time()
241
+ last_pinged_reset_at, last_ping_at = _load_ping_state()
242
+ if not should_ping(
243
+ now,
244
+ current_reset_at,
245
+ enabled,
246
+ last_pinged_reset_at,
247
+ last_ping_at,
248
+ current_percent,
249
+ data_source,
250
+ ):
251
+ return
252
+ if not _try_acquire():
253
+ return
254
+ # Mark this boundary handled at dispatch time regardless of subprocess
255
+ # outcome, so a failed ping doesn't retry on every refresh.
256
+ assert current_reset_at is not None # guaranteed by should_ping
257
+ _save_ping_state(current_reset_at, now)
258
+ worker = threading.Thread(target=_ping_worker, args=(now,), daemon=True)
259
+ worker.start()
260
+
261
+
262
+ def _debug_log(message: str, *, exc_info: bool = False) -> None:
263
+ if os.environ.get("USAGE_DEBUG") == "1":
264
+ logger.warning(message, exc_info=exc_info)
windows_watch.py ADDED
@@ -0,0 +1,443 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ """Native Windows file notifications for tray usage refreshes."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import logging
13
+ import os
14
+ import struct
15
+ import sys
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from ctypes import wintypes
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import codex_loader
25
+ import history_loader
26
+ from adapters import rate_limits
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Values verified against the Windows SDK documentation for CreateFileW,
31
+ # ReadDirectoryChangesW, WaitForMultipleObjects, and GetOverlappedResult.
32
+ _FILE_LIST_DIRECTORY = 0x0001
33
+ _FILE_SHARE_READ = 0x00000001
34
+ _FILE_SHARE_WRITE = 0x00000002
35
+ _FILE_SHARE_DELETE = 0x00000004
36
+ _OPEN_EXISTING = 3
37
+ _FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
38
+ _FILE_FLAG_OVERLAPPED = 0x40000000
39
+ _FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001
40
+ _FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002
41
+ _FILE_NOTIFY_CHANGE_SIZE = 0x00000008
42
+ _FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010
43
+ _FILE_NOTIFY_CHANGE_CREATION = 0x00000040
44
+ _NOTIFY_FILTER = (
45
+ _FILE_NOTIFY_CHANGE_FILE_NAME
46
+ | _FILE_NOTIFY_CHANGE_DIR_NAME
47
+ | _FILE_NOTIFY_CHANGE_SIZE
48
+ | _FILE_NOTIFY_CHANGE_LAST_WRITE
49
+ | _FILE_NOTIFY_CHANGE_CREATION
50
+ )
51
+ _ERROR_OPERATION_ABORTED = 995
52
+ _ERROR_IO_PENDING = 997
53
+ _ERROR_NOTIFY_ENUM_DIR = 1022
54
+ _WAIT_OBJECT_0 = 0
55
+ _WAIT_FAILED = 0xFFFFFFFF
56
+ _INFINITE = 0xFFFFFFFF
57
+ _WATCH_BUFFER_BYTES = 64 * 1024
58
+
59
+ _FILE_ACTION_ADDED = 1
60
+ _FILE_ACTION_REMOVED = 2
61
+ _FILE_ACTION_MODIFIED = 3
62
+ _FILE_ACTION_RENAMED_OLD_NAME = 4
63
+ _FILE_ACTION_RENAMED_NEW_NAME = 5
64
+ _REMOVAL_ACTIONS = frozenset({_FILE_ACTION_REMOVED, _FILE_ACTION_RENAMED_OLD_NAME})
65
+
66
+
67
+ class _Overlapped(ctypes.Structure):
68
+ _fields_ = [
69
+ ("Internal", ctypes.c_size_t),
70
+ ("InternalHigh", ctypes.c_size_t),
71
+ ("Offset", wintypes.DWORD),
72
+ ("OffsetHigh", wintypes.DWORD),
73
+ ("hEvent", wintypes.HANDLE),
74
+ ]
75
+
76
+
77
+ _kernel32: Any = None
78
+ if sys.platform == "win32":
79
+ try:
80
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
81
+ _kernel32.CreateFileW.restype = wintypes.HANDLE
82
+ _kernel32.CreateFileW.argtypes = [
83
+ wintypes.LPCWSTR,
84
+ wintypes.DWORD,
85
+ wintypes.DWORD,
86
+ wintypes.LPVOID,
87
+ wintypes.DWORD,
88
+ wintypes.DWORD,
89
+ wintypes.HANDLE,
90
+ ]
91
+ _kernel32.ReadDirectoryChangesW.restype = wintypes.BOOL
92
+ _kernel32.ReadDirectoryChangesW.argtypes = [
93
+ wintypes.HANDLE,
94
+ wintypes.LPVOID,
95
+ wintypes.DWORD,
96
+ wintypes.BOOL,
97
+ wintypes.DWORD,
98
+ wintypes.LPVOID,
99
+ ctypes.POINTER(_Overlapped),
100
+ wintypes.LPVOID,
101
+ ]
102
+ _kernel32.CreateEventW.restype = wintypes.HANDLE
103
+ _kernel32.CreateEventW.argtypes = [
104
+ wintypes.LPVOID,
105
+ wintypes.BOOL,
106
+ wintypes.BOOL,
107
+ wintypes.LPCWSTR,
108
+ ]
109
+ _kernel32.SetEvent.restype = wintypes.BOOL
110
+ _kernel32.SetEvent.argtypes = [wintypes.HANDLE]
111
+ _kernel32.ResetEvent.restype = wintypes.BOOL
112
+ _kernel32.ResetEvent.argtypes = [wintypes.HANDLE]
113
+ _kernel32.WaitForMultipleObjects.restype = wintypes.DWORD
114
+ _kernel32.WaitForMultipleObjects.argtypes = [
115
+ wintypes.DWORD,
116
+ ctypes.POINTER(wintypes.HANDLE),
117
+ wintypes.BOOL,
118
+ wintypes.DWORD,
119
+ ]
120
+ _kernel32.GetOverlappedResult.restype = wintypes.BOOL
121
+ _kernel32.GetOverlappedResult.argtypes = [
122
+ wintypes.HANDLE,
123
+ ctypes.POINTER(_Overlapped),
124
+ ctypes.POINTER(wintypes.DWORD),
125
+ wintypes.BOOL,
126
+ ]
127
+ _kernel32.CancelIoEx.restype = wintypes.BOOL
128
+ _kernel32.CancelIoEx.argtypes = [
129
+ wintypes.HANDLE,
130
+ ctypes.POINTER(_Overlapped),
131
+ ]
132
+ _kernel32.CloseHandle.restype = wintypes.BOOL
133
+ _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
134
+ except (AttributeError, OSError):
135
+ _kernel32 = None
136
+
137
+ _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class WindowsFileEventChanges:
142
+ paths: frozenset[Path]
143
+ needs_full_scan: bool = False
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class WindowsWatchSpec:
148
+ root: Path
149
+ recursive: bool
150
+ filenames: frozenset[str] = frozenset()
151
+ history_jsonl: bool = False
152
+
153
+ def classify(self, path: Path, action: int) -> WindowsFileEventChanges:
154
+ if self.filenames:
155
+ if path.name.casefold() in self.filenames:
156
+ return WindowsFileEventChanges(frozenset({path}))
157
+ return WindowsFileEventChanges(frozenset())
158
+
159
+ if self.history_jsonl and path.suffix.casefold() == ".jsonl":
160
+ return WindowsFileEventChanges(frozenset({path}))
161
+ if self.history_jsonl:
162
+ # FILE_NOTIFY_INFORMATION does not carry a file-vs-directory bit.
163
+ # Existing directories and removed/old-name entries may represent
164
+ # a whole subtree, so match FSEvents by requesting a full scan.
165
+ try:
166
+ is_directory = path.is_dir()
167
+ except OSError:
168
+ is_directory = False
169
+ if is_directory or action in _REMOVAL_ACTIONS:
170
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
171
+ return WindowsFileEventChanges(frozenset())
172
+
173
+
174
+ def usage_watch_specs() -> list[WindowsWatchSpec]:
175
+ """Return only the existing directories that contain tray usage sources."""
176
+ status_paths = tuple(
177
+ Path(path)
178
+ for path in (
179
+ rate_limits.STATUS_FILE,
180
+ rate_limits.LEGACY_STATUS_FILE,
181
+ rate_limits.TT_STATUS_FILE,
182
+ )
183
+ )
184
+ specs: list[WindowsWatchSpec] = []
185
+ status_root = status_paths[0].parent
186
+ if status_root.is_dir():
187
+ specs.append(
188
+ WindowsWatchSpec(
189
+ status_root,
190
+ recursive=False,
191
+ filenames=frozenset(path.name.casefold() for path in status_paths),
192
+ )
193
+ )
194
+
195
+ for root in (
196
+ history_loader.CLAUDE_PROJECTS_DIR,
197
+ codex_loader.SESSIONS_DIR,
198
+ codex_loader.ARCHIVED_SESSIONS_DIR,
199
+ ):
200
+ if root.is_dir():
201
+ specs.append(WindowsWatchSpec(root, recursive=True, history_jsonl=True))
202
+ return specs
203
+
204
+
205
+ def _parse_notifications(
206
+ spec: WindowsWatchSpec,
207
+ buffer: ctypes.Array[ctypes.c_char],
208
+ byte_count: int,
209
+ ) -> WindowsFileEventChanges:
210
+ if byte_count == 0:
211
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
212
+
213
+ dirty_paths: set[Path] = set()
214
+ needs_full_scan = False
215
+ offset = 0
216
+ while True:
217
+ if byte_count - offset < 12:
218
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
219
+ next_offset, action, name_bytes = struct.unpack_from("<III", buffer, offset)
220
+ name_start = offset + 12
221
+ name_end = name_start + name_bytes
222
+ if name_bytes % 2 or name_end > byte_count:
223
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
224
+ try:
225
+ relative_name = buffer.raw[name_start:name_end].decode("utf-16-le")
226
+ except UnicodeDecodeError:
227
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
228
+ classified = spec.classify(spec.root / Path(relative_name), action)
229
+ dirty_paths.update(classified.paths)
230
+ needs_full_scan = needs_full_scan or classified.needs_full_scan
231
+
232
+ if next_offset == 0:
233
+ break
234
+ if next_offset < 12 or offset + next_offset >= byte_count:
235
+ return WindowsFileEventChanges(frozenset(), needs_full_scan=True)
236
+ offset += next_offset
237
+ return WindowsFileEventChanges(frozenset(dirty_paths), needs_full_scan)
238
+
239
+
240
+ @dataclass(slots=True)
241
+ class _WatchRuntime:
242
+ spec: WindowsWatchSpec
243
+ directory_handle: Any
244
+ io_event: Any
245
+ ready: threading.Event
246
+ thread: threading.Thread | None = None
247
+
248
+
249
+ class WindowsUsageWatcher:
250
+ """Own ReadDirectoryChangesW workers and stop them without blocking the tray."""
251
+
252
+ def __init__(
253
+ self,
254
+ specs: list[WindowsWatchSpec],
255
+ callback: Callable[[WindowsFileEventChanges], None],
256
+ ) -> None:
257
+ self._specs = specs
258
+ self._callback = callback
259
+ self._stop_handle: Any = None
260
+ self._runtimes: list[_WatchRuntime] = []
261
+ self._stop_lock = threading.Lock()
262
+ self._stopped = False
263
+
264
+ def start(self) -> bool:
265
+ if _kernel32 is None or not self._specs:
266
+ return False
267
+ self._stop_handle = _kernel32.CreateEventW(None, True, False, None)
268
+ if not self._stop_handle:
269
+ return False
270
+
271
+ for spec in self._specs:
272
+ directory_handle = _kernel32.CreateFileW(
273
+ str(spec.root),
274
+ _FILE_LIST_DIRECTORY,
275
+ _FILE_SHARE_READ | _FILE_SHARE_WRITE | _FILE_SHARE_DELETE,
276
+ None,
277
+ _OPEN_EXISTING,
278
+ _FILE_FLAG_BACKUP_SEMANTICS | _FILE_FLAG_OVERLAPPED,
279
+ None,
280
+ )
281
+ if directory_handle in (None, _INVALID_HANDLE_VALUE):
282
+ self._debug_warning("Could not open Windows usage watch path %s", spec.root)
283
+ continue
284
+ io_event = _kernel32.CreateEventW(None, True, False, None)
285
+ if not io_event:
286
+ _kernel32.CloseHandle(directory_handle)
287
+ continue
288
+ runtime = _WatchRuntime(spec, directory_handle, io_event, threading.Event())
289
+ runtime.thread = threading.Thread(
290
+ target=self._watch_directory,
291
+ args=(runtime,),
292
+ name=f"usage-watch-{spec.root.name}",
293
+ daemon=True,
294
+ )
295
+ self._runtimes.append(runtime)
296
+ runtime.thread.start()
297
+
298
+ for runtime in self._runtimes:
299
+ runtime.ready.wait(2.0)
300
+ if any(
301
+ runtime.thread is not None and runtime.thread.is_alive()
302
+ for runtime in self._runtimes
303
+ ):
304
+ return True
305
+ self.stop()
306
+ return False
307
+
308
+ def stop(self, timeout: float = 3.0) -> None:
309
+ with self._stop_lock:
310
+ if self._stopped:
311
+ return
312
+ self._stopped = True
313
+ stop_handle = self._stop_handle
314
+ if stop_handle:
315
+ _kernel32.SetEvent(stop_handle)
316
+
317
+ deadline = time.monotonic() + timeout
318
+ current = threading.current_thread()
319
+ for runtime in self._runtimes:
320
+ thread = runtime.thread
321
+ if thread is not None and thread is not current:
322
+ thread.join(max(0.0, deadline - time.monotonic()))
323
+
324
+ if any(
325
+ runtime.thread is not None and runtime.thread.is_alive()
326
+ for runtime in self._runtimes
327
+ ):
328
+ self._debug_warning("Windows usage watcher did not stop cleanly")
329
+ return
330
+ if stop_handle:
331
+ _kernel32.CloseHandle(stop_handle)
332
+ self._stop_handle = None
333
+
334
+ def _watch_directory(self, runtime: _WatchRuntime) -> None:
335
+ buffer = ctypes.create_string_buffer(_WATCH_BUFFER_BYTES)
336
+ overlapped = _Overlapped()
337
+ overlapped.hEvent = runtime.io_event
338
+ wait_handles = (wintypes.HANDLE * 2)(self._stop_handle, runtime.io_event)
339
+ set_last_error_name = "set_last_error"
340
+ set_last_error: Any = getattr(ctypes, set_last_error_name)
341
+ get_last_error_name = "get_last_error"
342
+ get_last_error: Any = getattr(ctypes, get_last_error_name)
343
+ try:
344
+ while True:
345
+ _kernel32.ResetEvent(runtime.io_event)
346
+ set_last_error(0)
347
+ submitted = _kernel32.ReadDirectoryChangesW(
348
+ runtime.directory_handle,
349
+ buffer,
350
+ len(buffer),
351
+ runtime.spec.recursive,
352
+ _NOTIFY_FILTER,
353
+ None,
354
+ ctypes.byref(overlapped),
355
+ None,
356
+ )
357
+ error = get_last_error()
358
+ if not submitted and error != _ERROR_IO_PENDING:
359
+ runtime.ready.set()
360
+ if error == _ERROR_NOTIFY_ENUM_DIR:
361
+ self._dispatch(WindowsFileEventChanges(frozenset(), True))
362
+ else:
363
+ self._debug_warning(
364
+ "ReadDirectoryChangesW failed for %s (error %s)",
365
+ runtime.spec.root,
366
+ error,
367
+ )
368
+ return
369
+ runtime.ready.set()
370
+
371
+ wait_result = _kernel32.WaitForMultipleObjects(
372
+ 2,
373
+ wait_handles,
374
+ False,
375
+ _INFINITE,
376
+ )
377
+ if wait_result == _WAIT_OBJECT_0:
378
+ _kernel32.CancelIoEx(runtime.directory_handle, ctypes.byref(overlapped))
379
+ transferred = wintypes.DWORD()
380
+ _kernel32.GetOverlappedResult(
381
+ runtime.directory_handle,
382
+ ctypes.byref(overlapped),
383
+ ctypes.byref(transferred),
384
+ True,
385
+ )
386
+ return
387
+ if wait_result != _WAIT_OBJECT_0 + 1:
388
+ if wait_result == _WAIT_FAILED:
389
+ self._debug_warning(
390
+ "WaitForMultipleObjects failed for %s (error %s)",
391
+ runtime.spec.root,
392
+ get_last_error(),
393
+ )
394
+ return
395
+
396
+ transferred = wintypes.DWORD()
397
+ set_last_error(0)
398
+ completed = _kernel32.GetOverlappedResult(
399
+ runtime.directory_handle,
400
+ ctypes.byref(overlapped),
401
+ ctypes.byref(transferred),
402
+ False,
403
+ )
404
+ if not completed:
405
+ error = get_last_error()
406
+ if error == _ERROR_OPERATION_ABORTED and self._stopped:
407
+ return
408
+ self._dispatch(WindowsFileEventChanges(frozenset(), True))
409
+ continue
410
+ changes = _parse_notifications(runtime.spec, buffer, transferred.value)
411
+ if changes.paths or changes.needs_full_scan:
412
+ self._dispatch(changes)
413
+ finally:
414
+ runtime.ready.set()
415
+ _kernel32.CloseHandle(runtime.io_event)
416
+ _kernel32.CloseHandle(runtime.directory_handle)
417
+
418
+ def _dispatch(self, changes: WindowsFileEventChanges) -> None:
419
+ if self._stopped:
420
+ return
421
+ try:
422
+ self._callback(changes)
423
+ except Exception:
424
+ self._debug_warning("Windows usage watcher callback failed", exc_info=True)
425
+
426
+ @staticmethod
427
+ def _debug_warning(
428
+ message: str,
429
+ *args: object,
430
+ exc_info: bool = False,
431
+ ) -> None:
432
+ if os.environ.get("USAGE_DEBUG") == "1":
433
+ logger.warning(message, *args, exc_info=exc_info)
434
+
435
+
436
+ def setup_windows_watcher(
437
+ callback: Callable[[WindowsFileEventChanges], None],
438
+ *,
439
+ specs: list[WindowsWatchSpec] | None = None,
440
+ ) -> WindowsUsageWatcher | None:
441
+ """Start native usage-source watchers, returning None when unavailable."""
442
+ watcher = WindowsUsageWatcher(usage_watch_specs() if specs is None else specs, callback)
443
+ return watcher if watcher.start() else None