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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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
|