Playwright-based Claude plan usage scraper#31
Merged
Conversation
With the daemon now advertising as codexmeter-{provider} (codex/claude)
instead of a shared "codexmeter" name, iOS was still assigning every
discovery to codexServerURL. Whichever daemon's advertisement arrived
first would land in that slot, so the codex display could end up
pointing at the claude daemon (or vice versa) and both providers would
render identical data.
processDiscovery now parses the provider from the service instance
name suffix:
- codexmeter-codex -> codexServerURL (authoritative; overrides stale)
- codexmeter-claude -> claudeServerURL (authoritative; overrides stale)
- legacy codexmeter (no suffix) -> codexServerURL only if empty,
so it doesn't stomp a real codexmeter-codex that came first.
The "override stale value" behavior is needed to recover users whose
codexServerURL was captured before per-provider names existed and now
points to the wrong daemon.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous routing used substring tests ("claude" in name → claude,
otherwise codex), which would mis-route unknown provider variants such
as a "codexmeter-opencode" or "codexmeter-test" announcement into the
codex slot and stomp the real codex URL.
Now match exact instance names: codexmeter-codex, codexmeter-claude,
or bare codexmeter (legacy, codex slot, fill-if-empty only). Anything
else still appears in discoveredServices but is not assigned to a
provider URL slot.
Verified against six scenarios (fresh, stale-correction, legacy
respect, legacy fill, unknown-variant ignore, case-insensitive).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation raises "invalid reuse after initialization failure" if the same NetServiceBrowser is reused after its previous search ended in a failed state (e.g. didNotSearch). With per-provider mDNS the browser runs through more start/stop cycles and is more likely to hit it. Replace the shared NetServiceBrowser with a var that we drop and recreate inside startBrowsing(). Also stop+detach any in-flight NetService resolves in stopBrowsing() so they aren't reused either. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When mDNS routing corrects a stale provider URL (e.g. codexServerURL was pointing at the claude daemon from the pre-fix bug period), the cached JSON in UserDefaults.sharedGroup is still wrong-provider data. The widget would keep rendering it until its next live fetch, which the user saw as both Codex and Claude tiles showing identical claude numbers (0% / 91% / reset 0m). processDiscovery now: - Detects when a slot's URL changed (vs. first-time set). - Clears the per-provider cached JSON + timestamp from the shared app group (and the legacy "last_usage_json" fallback for the codex slot). - Triggers an immediate fetchUsage so the correct payload lands ASAP. - Calls WidgetCenter.reloadAllTimelines() so the widget re-renders. After this change, launching the app once on the new build is enough for the widget to self-heal — no manual UserDefaults clearing needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix (recreate NetServiceBrowser on each start) addressed one source of "invalid reuse after initialization failure", but the NSException is also raised by NetService.resolve(withTimeout:) when Apple's browser re-delivers an already-resolved or already-failed NetService instance. We were calling resolve on it again, which re-tripped the exception. Rewrite MDNSServiceBrowser using Network.framework's NWBrowser + NWConnection. The modern API doesn't surface the legacy NSException failure modes at all: - NWBrowser handles re-announcements internally without re-delivering state objects to the caller. - NWConnection resolves the endpoint to host:port via its path, and cancel/restart is well-defined — no "reuse" hazard. Public API (startBrowsing, stopBrowsing, discoveryPublisher, discoveriesAsync, firstDiscovery) is unchanged so callers don't move. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hook snapshot is the highest-priority claude source. Claude Code's statusline writes ~/.claude/plan-limits.json on every status-line tick with the current five_hour / seven_day used_percentage and resets_at, but it does NOT zero out used_percentage when the window actually rolls over — it just keeps reporting the last in-window numbers until the next session burns into the new window. So immediately after a boundary, the file shows e.g. "five_hour: 100% used, resets_at = 8 minutes ago" even though the window has reset. Result: the claude daemon was returning s:0, sr:0, status "5h 100% used" while the plan-usage scraper had already captured fresh data (s:48, sr:261, "5h 52% used"). The widget rendered the stale hook data and showed 0%/0m left on the claude tile. Fix: in claude_hook_limits_snapshot, if either window's resets_at is in the past, treat the file as logically stale and return None so the loop falls through to the plan-usage / rate-limit / activity sources that do have current numbers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing scripts/claude-plan-usage-cache.py extracts plan usage by parsing the visible text of claude.ai/settings/usage. That stopped matching as Claude's markup evolved — the daemon error log filled with "Claude usage text did not include recognizable plan usage" and the cached claude-plan-usage.json went stale, which is the upstream cause of the widget's claude tile showing wrong / stuck data. This new scraper goes through claude.ai's JSON API directly via a saved Playwright session: - `login` opens a real Chromium so the user signs in once; saves storage state to ~/.config/codexmeter/claude-state.json. - `scrape` (default) launches headless Chromium with that state and hits https://claude.ai/api/organizations/{org}/usage, then writes a normalized payload to ~/.config/codexmeter/claude-plan-usage.json in the exact shape claude_plan_usage_snapshot() in the daemon expects (session / weekly dicts with used_pct + reset_mins). - `print` does the scrape without writing the cache. The cache contract is unchanged, so the daemon picks it up automatically — no daemon changes required. Also picks up the Xcode-recommended project defaults bump (LastUpgradeCheck 1600 -> 2650, ENABLE_USER_SCRIPT_SANDBOXING, STRING_CATALOG_GENERATE_SYMBOLS) and matching scheme updates that Xcode applied during recent device builds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbb1c35588
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
scripts/claude-plan-usage-playwright.py, a Playwright-driven scraper that hitsclaude.ai/api/organizations/{org}/usagewith a saved login session and writes the normalized payload the daemon expects (~/.config/codexmeter/claude-plan-usage.json).claude-plan-usage-cache.pythat had been failing withClaude usage text did not include recognizable plan usage— the root cause of stale claude tile data in the widget.LastUpgradeCheck 1600 → 2650,ENABLE_USER_SCRIPT_SANDBOXING,STRING_CATALOG_GENERATE_SYMBOLS) and the matching scheme updates.Notes for the reviewer
daemon/codex-usage-daemon.py:claude_plan_usage_snapshotconsumes the samesession/weeklyshape, so no daemon edits are needed.python scripts/claude-plan-usage-playwright.py loginopens Chromium so you can sign in to claude.ai; the session is persisted to~/.config/codexmeter/claude-state.json. After that, the script runs headless.fix/claude-multi-source-limits(PR Fix mDNS registration for multi-provider daemons #30) since it's based off its tip — happy to rebase ontomainafter Fix mDNS registration for multi-provider daemons #30 merges if you'd rather keep it independent.Test plan
python scripts/claude-plan-usage-playwright.py loginopens Chromium and saves state on successful login.python scripts/claude-plan-usage-playwright.py scrapewrites a fresh~/.config/codexmeter/claude-plan-usage.jsonwithsession.used_pct,session.reset_mins,weekly.used_pct,weekly.reset_mins.launchctl kickstart -k gui/$UID/com.justin.codexmeter.claude) and confirmcurl localhost:9596/usagereflects the fresh plan numbers (no longer stuck on the stale hook payload).🤖 Generated with Claude Code