tokenmaxxing 0.1.2__tar.gz → 0.1.4__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.
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/PKG-INFO +1 -1
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/pyproject.toml +1 -1
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/src/tokenmaxxing/__init__.py +1 -1
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/src/tokenmaxxing/app.py +103 -36
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/.gitignore +0 -0
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/LICENSE +0 -0
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/README.md +0 -0
- {tokenmaxxing-0.1.2 → tokenmaxxing-0.1.4}/src/tokenmaxxing/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenmaxxing
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alvations/tokenmaxxing
|
|
6
6
|
Project-URL: Repository, https://github.com/alvations/tokenmaxxing
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tokenmaxxing"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -387,6 +387,46 @@ def _compute_min_max_avg(snapshots: list, key: str, now_ts: int):
|
|
|
387
387
|
return min(vals), sum(vals) / len(vals), max(vals)
|
|
388
388
|
|
|
389
389
|
|
|
390
|
+
def _lift_claude_env_token() -> Optional[str]:
|
|
391
|
+
"""Find a fresh CLAUDE_CODE_OAUTH_TOKEN from a running `claude` process.
|
|
392
|
+
|
|
393
|
+
Pro/Max users can't refresh the keychain token in-place (refreshToken is
|
|
394
|
+
empty). After expiry (~24 h), the keychain token returns 401 forever,
|
|
395
|
+
triggering sustained 429s on the OAuth usage endpoint — the menu bar
|
|
396
|
+
locks into "[rate limited]" with no recovery short of starting a new
|
|
397
|
+
`claude` session.
|
|
398
|
+
|
|
399
|
+
A running `claude` parent process has the fresh token in its env vars.
|
|
400
|
+
Lifting it lets tokenmaxxing keep polling indefinitely as long as any
|
|
401
|
+
claude session is alive.
|
|
402
|
+
"""
|
|
403
|
+
env_tok = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
404
|
+
if env_tok:
|
|
405
|
+
return env_tok
|
|
406
|
+
try:
|
|
407
|
+
out = subprocess.check_output(
|
|
408
|
+
["pgrep", "-x", "claude"],
|
|
409
|
+
stderr=subprocess.DEVNULL,
|
|
410
|
+
timeout=KEYCHAIN_TIMEOUT,
|
|
411
|
+
).decode()
|
|
412
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
413
|
+
return None
|
|
414
|
+
pids = [p.strip() for p in out.splitlines() if p.strip()]
|
|
415
|
+
for pid in pids:
|
|
416
|
+
try:
|
|
417
|
+
env_out = subprocess.check_output(
|
|
418
|
+
["ps", "-E", "-p", pid],
|
|
419
|
+
stderr=subprocess.DEVNULL,
|
|
420
|
+
timeout=KEYCHAIN_TIMEOUT,
|
|
421
|
+
).decode()
|
|
422
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
423
|
+
continue
|
|
424
|
+
for tok in env_out.split():
|
|
425
|
+
if tok.startswith("CLAUDE_CODE_OAUTH_TOKEN="):
|
|
426
|
+
return tok.split("=", 1)[1]
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
390
430
|
def _get_oauth_data() -> Optional[dict]:
|
|
391
431
|
"""Read Claude Code's OAuth credentials from keychain or file. Returns the full claudeAiOauth dict."""
|
|
392
432
|
user = os.environ.get("USER", "")
|
|
@@ -422,9 +462,15 @@ def get_access_token() -> Optional[str]:
|
|
|
422
462
|
|
|
423
463
|
def fetch_usage(oauth_data: Optional[dict] = None):
|
|
424
464
|
"""Returns (payload_dict, error_str, is_rate_limited). One of payload/error is always None."""
|
|
425
|
-
token
|
|
465
|
+
# Prefer a fresh token lifted from a running `claude` process — the
|
|
466
|
+
# keychain token can't refresh in-place for Pro/Max users and will 401
|
|
467
|
+
# once it expires. Fall back to the keychain only if no claude session
|
|
468
|
+
# is alive.
|
|
469
|
+
token = _lift_claude_env_token()
|
|
470
|
+
if not token and oauth_data:
|
|
471
|
+
token = oauth_data.get("accessToken")
|
|
426
472
|
if not token:
|
|
427
|
-
return None, "no Claude Code token", False
|
|
473
|
+
return None, "no Claude Code token (start a claude session)", False
|
|
428
474
|
req = urllib.request.Request(
|
|
429
475
|
USAGE_URL,
|
|
430
476
|
headers={
|
|
@@ -440,7 +486,7 @@ def fetch_usage(oauth_data: Optional[dict] = None):
|
|
|
440
486
|
if e.code == HTTP_STATUS_RATE_LIMITED:
|
|
441
487
|
return None, "rate limited — data is stale, retrying soon", True
|
|
442
488
|
if e.code == HTTP_STATUS_UNAUTHORIZED:
|
|
443
|
-
return None, "auth expired (
|
|
489
|
+
return None, "auth expired (start a claude session)", False
|
|
444
490
|
return None, f"HTTP {e.code}", False
|
|
445
491
|
except urllib.error.URLError as e:
|
|
446
492
|
return None, f"net: {e.reason}", False
|
|
@@ -604,42 +650,58 @@ class ClaudeMonitorApp(rumps.App):
|
|
|
604
650
|
# ----- worker / dispatch -----
|
|
605
651
|
|
|
606
652
|
def _worker_loop(self):
|
|
653
|
+
"""Background poll loop. Must NEVER die — any uncaught exception
|
|
654
|
+
leaves the UI frozen on the last state forever, with no further
|
|
655
|
+
polls to recover."""
|
|
607
656
|
consecutive_failures = 0
|
|
608
657
|
while True:
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
658
|
+
try:
|
|
659
|
+
now = time.time()
|
|
660
|
+
if now < self._backoff_until:
|
|
661
|
+
self._wake.wait(timeout=min(REFRESH_SECONDS, self._backoff_until - now))
|
|
662
|
+
self._wake.clear()
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
oauth_data = _get_oauth_data()
|
|
666
|
+
payload, err, is_rate_limited = fetch_usage(oauth_data)
|
|
667
|
+
# Drop any Refresh-now clicks that arrived during the poll — the
|
|
668
|
+
# in-flight poll already satisfies them, and re-polling immediately
|
|
669
|
+
# can trip the OAuth endpoint's rate limit.
|
|
612
670
|
self._wake.clear()
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
#
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
671
|
+
with self._pending_lock:
|
|
672
|
+
self._pending = (payload, err, is_rate_limited, oauth_data)
|
|
673
|
+
|
|
674
|
+
if payload is not None:
|
|
675
|
+
consecutive_failures = 0
|
|
676
|
+
self._backoff_until = 0
|
|
677
|
+
self._record_snapshot(payload)
|
|
678
|
+
sleep_for = REFRESH_SECONDS
|
|
679
|
+
elif is_rate_limited:
|
|
680
|
+
consecutive_failures += 1
|
|
681
|
+
backoff = _calc_backoff(consecutive_failures)
|
|
682
|
+
self._backoff_until = time.time() + backoff
|
|
683
|
+
# Retry as soon as the backoff window allows, instead of waiting
|
|
684
|
+
# the full REFRESH_SECONDS — otherwise the menu keeps showing
|
|
685
|
+
# "[rate limited]" for ~5 min after a single transient 429 even
|
|
686
|
+
# when the API recovered within seconds.
|
|
687
|
+
sleep_for = backoff
|
|
688
|
+
else:
|
|
689
|
+
sleep_for = REFRESH_SECONDS
|
|
690
|
+
|
|
691
|
+
self._wake.wait(timeout=sleep_for)
|
|
692
|
+
self._wake.clear()
|
|
693
|
+
except Exception as e: # noqa: BLE001
|
|
694
|
+
# Surface the failure to the UI as a transient error and keep
|
|
695
|
+
# looping. Without this, a single bad poll kills polling forever.
|
|
696
|
+
print(f"tokenmaxxing worker: {type(e).__name__}: {e}", file=sys.stderr)
|
|
697
|
+
with self._pending_lock:
|
|
698
|
+
self._pending = (None, f"worker error: {type(e).__name__}", False, None)
|
|
699
|
+
# Avoid a tight loop if the exception happens immediately.
|
|
700
|
+
try:
|
|
701
|
+
self._wake.wait(timeout=REFRESH_SECONDS)
|
|
702
|
+
self._wake.clear()
|
|
703
|
+
except Exception: # noqa: BLE001
|
|
704
|
+
time.sleep(REFRESH_SECONDS)
|
|
643
705
|
|
|
644
706
|
def _apply_pending(self, _sender):
|
|
645
707
|
with self._pending_lock:
|
|
@@ -665,6 +727,11 @@ class ClaudeMonitorApp(rumps.App):
|
|
|
665
727
|
self._render()
|
|
666
728
|
|
|
667
729
|
def _manual_refresh(self, _sender):
|
|
730
|
+
# Force-clear any pending backoff so the click actually polls.
|
|
731
|
+
# Without this, when the worker is in backoff (e.g. after sustained
|
|
732
|
+
# 429s), the wake event fires but the loop top-checks _backoff_until
|
|
733
|
+
# and goes right back to waiting — the click is swallowed.
|
|
734
|
+
self._backoff_until = 0
|
|
668
735
|
self._wake.set()
|
|
669
736
|
|
|
670
737
|
def _record_snapshot(self, payload):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|