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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenmaxxing
3
- Version: 0.1.2
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.2"
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"
@@ -1,6 +1,6 @@
1
1
  """tokenmaxxing — menu bar app for Claude Code session and weekly usage."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
4
4
 
5
5
  from tokenmaxxing.app import main
6
6
 
@@ -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 = oauth_data.get("accessToken") if oauth_data else None
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 (run `claude` to refresh)", False
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
- now = time.time()
610
- if now < self._backoff_until:
611
- self._wake.wait(timeout=min(REFRESH_SECONDS, self._backoff_until - now))
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
- continue
614
-
615
- oauth_data = _get_oauth_data()
616
- payload, err, is_rate_limited = fetch_usage(oauth_data)
617
- # Drop any Refresh-now clicks that arrived during the poll — the
618
- # in-flight poll already satisfies them, and re-polling immediately
619
- # can trip the OAuth endpoint's rate limit.
620
- self._wake.clear()
621
- with self._pending_lock:
622
- self._pending = (payload, err, is_rate_limited, oauth_data)
623
-
624
- if payload is not None:
625
- consecutive_failures = 0
626
- self._backoff_until = 0
627
- self._record_snapshot(payload)
628
- sleep_for = REFRESH_SECONDS
629
- elif is_rate_limited:
630
- consecutive_failures += 1
631
- backoff = _calc_backoff(consecutive_failures)
632
- self._backoff_until = time.time() + backoff
633
- # Retry as soon as the backoff window allows, instead of waiting
634
- # the full REFRESH_SECONDS — otherwise the menu keeps showing
635
- # "[rate limited]" for ~5 min after a single transient 429 even
636
- # when the API recovered within seconds.
637
- sleep_for = backoff
638
- else:
639
- sleep_for = REFRESH_SECONDS
640
-
641
- self._wake.wait(timeout=sleep_for)
642
- self._wake.clear()
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