-
Notifications
You must be signed in to change notification settings - Fork 313
Expand file tree
/
Copy pathclaude_usage_daemon_windows.py
More file actions
562 lines (493 loc) · 23.4 KB
/
Copy pathclaude_usage_daemon_windows.py
File metadata and controls
562 lines (493 loc) · 23.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
#!/usr/bin/env python3
"""Claude Usage Tracker Daemon — Windows (Phase 2).
Reads the Claude OAuth token from the native-Windows credentials path and
polls the Anthropic API for rate-limit utilization data. BLE glue added in
later plans.
"""
import asyncio
import datetime
import json
import logging
import logging.handlers
import os
import re
import signal
import sys
import threading
import time
from pathlib import Path
import httpx
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
DEVICE_NAME = "Claude Controller"
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
POLL_INTERVAL = 60
TICK = 5
SCAN_TIMEOUT = 8.0
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
ZOMBIE_BREAK_LIMIT = 1 # D-03: consecutive write failures before abandoning a half-open link
# N=1: breaks at T=60s, leaves ~60s headroom for reconnect+poll inside 120s SLA
# N=2 would bust the 120s budget before reconnect even begins
RECONNECT_BACKOFF_CAP = 8 # D-05: fast-reconnect cap (seconds); keeps stacked retries inside 120s SLA
# ~5–10s band per CONTEXT.md Claude's Discretion; 8 chosen as middle ground
API_URL = "https://api.anthropic.com/v1/messages"
API_HEADERS_TEMPLATE = {
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
"Content-Type": "application/json",
"User-Agent": "claude-code/2.1.5",
}
API_BODY = {
"model": "claude-haiku-4-5-20251001",
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}],
}
def _build_file_logger() -> logging.Logger | None:
"""Create a rotating file logger for field diagnostics, or None.
Autostart launches the tray under pythonw.exe, which has no console — stdout
is discarded (and is in fact None, making print() unsafe). A rotating file is
then the ONLY trail when the daemon stalls in the field. Windows-only: on the
Linux dev box / CI the console print() suffices, and gating to win32 keeps the
pure-helper unit tests from writing stray log files.
"""
if sys.platform != "win32":
return None
logger = logging.getLogger("clawdmeter.daemon")
if logger.handlers:
return logger # idempotent across re-import (tray imports this module)
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
path = base / "Clawdmeter" / "daemon.log"
try:
path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
path, maxBytes=512 * 1024, backupCount=3, encoding="utf-8"
)
except OSError:
return None # best-effort — logging setup must never stop the daemon
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
return logger
_FILE_LOGGER = _build_file_logger()
def log(msg: str) -> None:
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
# Under pythonw sys.stdout is None and print() would raise — guard it so a
# missing console can never crash the daemon thread (the silent-freeze mode).
try:
print(line, flush=True)
except (OSError, ValueError, AttributeError, RuntimeError):
pass
if _FILE_LOGGER is not None:
_FILE_LOGGER.info(msg)
class AuthError(Exception):
"""Raised by poll_api on a genuine 401/403 — the token really is expired or
invalid and the user must re-run `claude login`. Distinct from a None return,
which means a TRANSIENT failure (network/DNS, timeout, rate-limit, 5xx) that
must NOT be mislabeled as a token problem (SC#5: a boot-time `getaddrinfo
failed` DNS blip wrongly fired the 'token expired' toast)."""
async def poll_api(token: str) -> dict | None:
headers = dict(API_HEADERS_TEMPLATE)
headers["Authorization"] = f"Bearer {token}"
try:
async with httpx.AsyncClient(timeout=20.0) as http:
resp = await http.post(API_URL, headers=headers, json=API_BODY)
except httpx.HTTPError as e:
# Network/DNS/timeout — transient. Return None (no toast), retry next tick.
log(f"API call failed: {e}")
return None
if resp.status_code in (401, 403):
# Genuine auth rejection — the ONLY case that warrants the actionable
# "run claude login" toast.
log(f"API HTTP {resp.status_code}: {resp.text[:200]}")
raise AuthError(resp.status_code)
if resp.status_code >= 400:
# Other 4xx/5xx (rate-limit, server error) — transient, not a token issue.
log(f"API HTTP {resp.status_code}: {resp.text[:200]}")
return None
H = "anthropic-ratelimit-unified-"
def raw(suffix: str) -> str | None:
"""Exact header value, or None when the header is absent."""
return resp.headers.get(H + suffix)
now = time.time()
def reset_minutes(reset_ts: str | None) -> int:
try:
r = float(reset_ts)
except (TypeError, ValueError):
return 0
mins = (r - now) / 60.0
return int(round(mins)) if mins > 0 else 0
def pct(util: str | None) -> int:
try:
return int(round(float(util) * 100))
except (TypeError, ValueError):
return 0
# Unified rate-limit schema: per-window utilization for the 5-hour (session)
# and 7-day (weekly) windows, with `representative-claim` naming the binding
# window (five_hour | seven_day | overage). Once the subscription allowance is
# spent the account enters OVERAGE mode: the API omits the -5h-/-7d- breakdown
# entirely, so reading only those windows yields all-zeros. Detect overage via
# `overage-in-use` and represent the spent subscription windows as full, rather
# than silently reporting 0%. Ref:
# https://github.com/anthropics/claude-code/issues/12829
s_util = raw("5h-utilization")
w_util = raw("7d-utilization")
# Capture window presence BEFORE the overage block below synthesises s/w=100,
# because acct keys on whether the API sent any 5h/7d family at all.
has_windows = s_util is not None or w_util is not None
overage_in_use = raw("overage-in-use") == "true"
# Overall status: prefer the unified headline; fall back to the per-window
# 5h-status for older responses that predate the unified-status header.
status = raw("status") or raw("5h-status") or "unknown"
if overage_in_use and s_util is None and w_util is None:
s_util = w_util = "1.0" # subscription exhausted -> both windows full
status = "overage"
# Account type (DEBT.md D-3). 5h/7d present -> "pro-max" (Pro and Max share
# the 5h/7d subscription model). No windows but overage in use -> "ent"
# (usage-based Enterprise: permanent dollar-spend overage, no windows). Default
# "pro-max" so a blank/unrecognised read keeps the normal Usage screen rather
# than the Enterprise spend gauge. The device branches screens on this.
acct = "pro-max" if has_windows else ("ent" if overage_in_use else "pro-max")
unified_reset = raw("reset") # reset for the representative claim
# Extra-usage (overage) spend: the real figure the device's "Extra Usage" bar
# shows. Distinct from the plan bar, which reads 100% ("allowance spent") once
# the subscription is exhausted. Absent outside overage -> pct(None) == 0, so
# the device never renders a phantom Extra Usage bar.
payload = {
"s": pct(s_util),
"sr": reset_minutes(raw("5h-reset") or unified_reset),
"w": pct(w_util),
"wr": reset_minutes(raw("7d-reset") or unified_reset),
"o": pct(raw("overage-utilization")),
"or": reset_minutes(unified_reset),
"oiu": overage_in_use,
"acct": acct,
"st": status,
"ok": True,
}
return payload
async def scan_for_device():
"""Scan for DEVICE_NAME and return the BLEDevice, or None."""
log(f"Scanning for '{DEVICE_NAME}' ({SCAN_TIMEOUT}s)...")
device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=SCAN_TIMEOUT)
if device:
log(f"Found: {device.address}")
return device # BLEDevice or None — NOT an address string
class Session:
def __init__(self, client: BleakClient) -> None:
self.client = client
self.refresh_requested = asyncio.Event()
def _on_refresh(self, _char, _data: bytearray) -> None:
log("Refresh requested by device")
self.refresh_requested.set()
async def setup_refresh_subscription(self) -> None:
# The refresh subscription is optional — the 60s poll loop works without it.
# WinRT's start_notify() CCCD write can raise a raw OSError/WinError (not
# wrapped as BleakError) when the peer GATT server is transiently unavailable,
# e.g. a just-power-cycled ESP32 whose server is not yet ready (G-03-01, SC#3).
# Degrade gracefully instead of crashing the daemon so it stays single-process
# across a power-cycle reconnect (SC#4, no restart).
try:
await self.client.start_notify(REQ_CHAR_UUID, self._on_refresh)
except (BleakError, ValueError, OSError) as e:
log(f"Refresh subscription unavailable: {e}")
async def write_payload(self, payload: dict) -> bool:
data = json.dumps(payload, separators=(",", ":")).encode()
log(f"Sending: {data.decode()}")
try:
await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False)
return True
except (BleakError, OSError) as e:
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError)
# when the peer GATT server goes transiently unavailable mid-write —
# the same failure class setup_refresh_subscription() guards against.
# Returning False trips the zombie-link break -> clean reconnect,
# rather than an uncaught exception killing the daemon thread (the
# silent-freeze failure mode, SC#2 field report).
log(f"Write failed: {e}")
return False
def _extract_access_token(blob: str) -> str | None:
"""Pull the accessToken out of a credentials blob.
Claude Code stores credentials as a JSON object; the blob may also be
nested ({"claudeAiOauth": {"accessToken": "..."}}). Fall back to a
regex match so unexpected shapes still work, and finally treat the
blob as a raw token if nothing else matches.
"""
blob = blob.strip()
if not blob:
return None
try:
data = json.loads(blob)
except json.JSONDecodeError:
data = None
if isinstance(data, dict):
# direct: {"accessToken": "..."}
tok = data.get("accessToken")
if isinstance(tok, str) and tok.strip():
return tok
# nested: {"claudeAiOauth": {"accessToken": "..."}}
for v in data.values():
if isinstance(v, dict):
tok = v.get("accessToken")
if isinstance(tok, str) and tok.strip():
return tok
m = re.search(r'"accessToken"\s*:\s*"([^"]+)"', blob)
if m:
return m.group(1)
# Raw token (no JSON wrapper) — must look plausible (sk-ant-... etc.)
if re.fullmatch(r"[A-Za-z0-9_\-.~+/=]{20,}", blob):
return blob
return None
def _windows_credential_candidates() -> list[Path]:
"""Return the ordered list of credential file paths to probe (first hit wins).
Priority:
1. CLAUDE_CREDENTIALS_PATH env override (D-03, project-specific)
2. CLAUDE_CONFIG_DIR env override (official Claude override)
3. D-02 candidate list: home/.claude, LOCALAPPDATA/Claude, APPDATA/Claude
"""
# Priority 1: project-specific env override (D-03)
if override := os.environ.get("CLAUDE_CREDENTIALS_PATH"):
return [Path(override)]
# Priority 2: official CLAUDE_CONFIG_DIR env override
if config_dir := os.environ.get("CLAUDE_CONFIG_DIR"):
return [Path(config_dir) / ".credentials.json"]
# Priority 3: D-02 candidate list — first hit wins
home = Path.home()
local_appdata = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
appdata = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
return [
home / ".claude" / ".credentials.json", # primary (confirmed by docs)
local_appdata / "Claude" / ".credentials.json", # fallback 2
appdata / "Claude" / ".credentials.json", # fallback 3
]
def read_token() -> str | None:
"""Read the Claude OAuth access token from the first available credential file."""
for path in _windows_credential_candidates():
try:
return _extract_access_token(path.read_text(encoding="utf-8"))
except OSError:
continue
return None
def _read_expiry() -> str:
"""Return human-readable expiry from the first-hit credentials file.
Reads claudeAiOauth.expiresAt (epoch milliseconds — JS convention).
Divides by 1000 before passing to fromtimestamp (Python expects seconds).
Returns 'expiry unknown' on any parse failure.
"""
for path in _windows_credential_candidates():
try:
raw = path.read_text(encoding="utf-8")
except OSError:
continue
try:
data = json.loads(raw)
oauth = data.get("claudeAiOauth", {})
expires_ms = oauth.get("expiresAt")
if expires_ms is None:
return "expiry unknown"
# CRITICAL: expiresAt is JS-convention epoch milliseconds; divide by 1000
# before fromtimestamp (Python expects seconds). Raw value -> year ~57000.
dt = datetime.datetime.fromtimestamp(
expires_ms / 1000, tz=datetime.timezone.utc
)
return dt.strftime("%Y-%m-%d %H:%M UTC")
except (TypeError, ValueError, OSError, AttributeError, json.JSONDecodeError):
return "expiry unknown"
return "expiry unknown"
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
"""Return when any of `events` is set, or after `timeout` seconds.
Lets the poll loop's TICK wait wake immediately on a stop signal (clean,
responsive Quit) without losing the refresh-request wakeup — instead of
waiting only on refresh_requested and re-checking stop_event up to TICK
later. Cancels and drains the loser tasks so they don't warn.
"""
tasks = [asyncio.ensure_future(e.wait()) for e in events]
try:
await asyncio.wait(tasks, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
"""Connect to device and poll until disconnected or stopped.
Returns True if at least one successful write occurred.
"""
log(f"Connecting to {device.address}...")
# D-01: retry wrapper — defeats WinRT post-wake failure modes
# (Could not get GATT services: Unreachable, stale is_connected).
# Rebuild a fresh BleakClient each attempt (locked D-05 recipe).
client = None
for attempt in range(CONNECT_RETRIES):
# D-05: pass BLEDevice (not address string), address_type="random" (NimBLE
# static-random), use_cached_services=False (DIY firmware — WinRT GATT cache
# may be stale after firmware reflash).
client = BleakClient(
device,
address_type="random",
use_cached_services=False,
)
try:
await client.connect()
except (BleakError, asyncio.TimeoutError) as e:
log(f"Connection attempt {attempt + 1}/{CONNECT_RETRIES} failed: {e}")
try:
await client.disconnect()
except BleakError:
pass
if attempt < CONNECT_RETRIES - 1:
await asyncio.sleep(CONNECT_RETRY_DELAY)
continue
if not client.is_connected:
log(f"Connection attempt {attempt + 1}/{CONNECT_RETRIES} failed (not connected)")
try:
await client.disconnect()
except BleakError:
pass
if attempt < CONNECT_RETRIES - 1:
await asyncio.sleep(CONNECT_RETRY_DELAY)
continue
# Connected successfully
break
else:
log(f"Connection failed after {CONNECT_RETRIES} attempts")
return False
log("Connected")
session = Session(client)
await session.setup_refresh_subscription()
last_poll = 0.0 # D-03: poll immediately on first connect
used_successfully = False
consecutive_failures = 0 # D-03: zombie-link break counter
try:
while client.is_connected and not stop_event.is_set():
now = time.time()
elapsed = now - last_poll
if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL:
session.refresh_requested.clear()
token = read_token() # D-09: fresh each cycle
if not token:
log("No token; skipping poll")
if tray_state:
tray_state.set_error("token expired — run claude login")
else:
try:
payload = await poll_api(token)
except AuthError:
# Real 401/403 — token genuinely needs a refresh.
if tray_state:
tray_state.set_error("token expired — run claude login")
payload = None
if payload is not None:
if await session.write_payload(payload):
last_poll = time.time()
used_successfully = True
consecutive_failures = 0 # D-03: reset on success
if tray_state:
tray_state.set_connected(time.time())
else:
consecutive_failures += 1
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
log(
f"Zombie link detected ({consecutive_failures} consecutive"
f" write failures); abandoning connection"
)
break
# else: payload is None from a TRANSIENT failure (network/DNS,
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT
# toast "token expired" — that mislabeled a boot-time DNS blip
# as an auth problem (SC#5). Leave tray state unchanged; the next
# tick retries and set_connected() recovers it.
# Wake on a refresh request OR a stop, whichever comes first. Waking
# promptly on stop_event is what lets the finally below run
# client.disconnect() before the process exits, so the peer gets a
# clean GATT disconnect (returns to its waiting screen) instead of
# being left frozen on stale data after Quit (SC#3 graceful shutdown).
await _wait_first(session.refresh_requested, stop_event, timeout=TICK)
finally:
# Clean GATT disconnect on the way out — this is what tells the peripheral
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
# so swallow both; the link tears down regardless once we exit.
try:
await client.disconnect()
except (BleakError, OSError):
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully
def _next_backoff(current: int, cap: int) -> int:
"""D-05: double current backoff value, clamped to cap.
Pure helper — unit-testable without driving the main loop.
Used by both slow-search (cap=60) and fast-reconnect (cap=RECONNECT_BACKOFF_CAP) regimes.
"""
return min(current * 2, cap)
async def main(tray_state=None) -> None:
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
# Populate the shared state object so the tray can route Quit through
# loop.call_soon_threadsafe (RESEARCH Pitfall 2). Additive — the existing
# stop_event = asyncio.Event() line above is unchanged.
if tray_state is not None:
tray_state.loop = loop
tray_state.stop_event = stop_event
def _stop(*_args: object) -> None:
log("Daemon stopping")
stop_event.set()
# OS signal handlers can only be installed from the main thread, and
# loop.add_signal_handler is unsupported on Windows. When running under the
# tray (04-03) the loop lives in a background thread and the tray owns clean
# shutdown via stop_event (loop.call_soon_threadsafe), so skip silently there.
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _stop)
except NotImplementedError:
# Windows: add_signal_handler not supported; fall back to signal.signal
try:
signal.signal(sig, _stop)
except ValueError:
# Not the main thread of the main interpreter — tray owns shutdown.
pass
log("=== Claude Usage Tracker Daemon (BLE, Windows) ===")
log(f"Poll interval: {POLL_INTERVAL}s")
# D-05: two distinct backoff regimes — slow-search (device absent) vs fast-reconnect (link dropped)
search_backoff = 1 # caps at 60s — gentle, for a device that is genuinely absent/off
reconnect_backoff = 1 # caps at RECONNECT_BACKOFF_CAP — fast, to clear the 120s SLA after a drop
while not stop_event.is_set():
device = await scan_for_device()
if not device:
# Slow-search regime: device was not found by scan — back off gently
if tray_state:
tray_state.set_scanning()
log(f"Device not found, retrying in {search_backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
except asyncio.TimeoutError:
pass
search_backoff = _next_backoff(search_backoff, 60)
continue
ok = await connect_and_run(device, stop_event, tray_state)
if not ok:
# Fast-reconnect regime: had/attempted a link that dropped — retry quickly
if tray_state:
tray_state.set_scanning()
log(f"Connection lost, reconnecting in {reconnect_backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=reconnect_backoff)
except asyncio.TimeoutError:
pass
reconnect_backoff = _next_backoff(reconnect_backoff, RECONNECT_BACKOFF_CAP)
else:
# Successful session — reset reconnect counter to floor; search_backoff also reset
reconnect_backoff = 1
search_backoff = 1
if __name__ == "__main__":
if sys.platform != "win32":
print(
"Warning: running under Linux/WSL — WinRT BLE will not be available.",
file=sys.stderr,
)
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)