Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ return a real snapshot wins.
`rate_limits` when Claude Code is in an interactive Pro/Max session
and Anthropic chooses to populate them; can be silent for hours.
TTL: `CLAUDE_HOOK_TTL_SECONDS` (default 15 min).
2. **`~/.config/codexmeter/claude-plan-usage.json`** — written by
`scripts/claude-plan-usage-firefox.py` (primary) every 60 s via the
`com.justin.codexmeter.claude-plan-scrape` LaunchAgent. Reads
`sessionKey` from Firefox's unencrypted `cookies.sqlite` and calls
`https://claude.ai/api/organizations/{org}/usage` directly. Structured
JSON — immune to claude.ai page-layout changes.
TTL: `CLAUDE_PLAN_TTL_SECONDS` (default 60 min).
2. **`~/.config/codexmeter/claude-plan-usage.json`** — written every 60 s
by the `com.justin.codexmeter.claude-plan-scrape` LaunchAgent. The
scrape has two cookie sources in priority order:
- **`scripts/claude-plan-usage-desktop.py`** (primary). Reads cookies
from Claude Desktop at `~/Library/Application Support/Claude/Cookies`,
decrypted via the `Claude Safe Storage` Keychain entry. Claude
Desktop renews `sessionKey` whenever you use it, so this source
basically never goes stale as long as you have the desktop app.
First run triggers a Keychain prompt — click "Always Allow".
- **`scripts/claude-plan-usage-firefox.py`** (fallback). Reads
`sessionKey` from Firefox's unencrypted `cookies.sqlite`. The
desktop script falls through to this automatically if Keychain
access fails or no Claude Desktop cookies are present.
Both call `https://claude.ai/api/organizations/{org}/usage` and
produce the same normalized JSON. Immune to claude.ai page-layout
changes. TTL: `CLAUDE_PLAN_TTL_SECONDS` (default 60 min).
3. **`~/.config/codexmeter/claude-statusline.json`** — legacy raw
statusline capture; same data as #1 but read from a different cache.
4. **Stale cached `claude-plan-usage.json`** — past TTL, surfaced with
Expand All @@ -51,15 +60,22 @@ This single endpoint tells you which sources are stale, what the last
scrape attempt said, and what snapshot the daemon is currently serving.
Common failure modes and what `/health` shows:

### A. Firefox session expired
### A. Cookie sessions expired
`scrape.ok = false`, message contains `HTTP 403` and
`account_session_invalid`. Fix:

```bash
./scripts/claude-plan-usage-firefox.py login # opens Firefox to /login
# sign in, then:
./scripts/claude-plan-usage-firefox.py
```
`account_session_invalid`. Two cases:

1. Claude Desktop still running and logged in → re-grant Keychain access
if the prompt was dismissed:
```bash
security find-generic-password -w -s "Claude Safe Storage" -a "Claude Key"
# If empty, open Keychain Access → search "Claude Safe Storage" →
# right-click → Access Control → Always Allow for /usr/bin/security
```
2. Both Claude Desktop and Firefox are signed out → re-login to Claude
Desktop (preferred) or run the Firefox fallback bootstrap:
```bash
./scripts/claude-plan-usage-firefox.py login
```

The cache should refresh within 60 s on the next LaunchAgent tick.

Expand Down Expand Up @@ -126,7 +142,8 @@ GET http://localhost:9596/health # diagnostic — source freshness, last scrap
| Path / process | What it is |
|---|---|
| `daemon/codex-usage-daemon.py` | HTTP daemon, polls every 30 s |
| `scripts/claude-plan-usage-firefox.py` | Primary scrape (Firefox cookies → claude.ai API) |
| `scripts/claude-plan-usage-desktop.py` | Primary scrape (Claude Desktop cookies → claude.ai API) |
| `scripts/claude-plan-usage-firefox.py` | Fallback scrape (Firefox cookies → claude.ai API) |
| `scripts/claude-plan-usage-cache.py` | Legacy Chrome AppleScript scrape — manual fallback only |
| `scripts/claude-statusline-limits.py` | Persists Claude Code statusLine `rate_limits` |
| `~/Library/LaunchAgents/com.justin.codexmeter.claude.plist` | Daemon (Claude provider, port 9596) |
Expand Down
4 changes: 2 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ PLIST
echo "Installed LaunchAgent: $PLIST_FILE"
echo "Logs: $HOME/Library/Logs/codexmeter.log"

SCRAPE_SCRIPT="$SCRIPT_DIR/scripts/claude-plan-usage-firefox.py"
SCRAPE_SCRIPT="$SCRIPT_DIR/scripts/claude-plan-usage-desktop.py"
SCRAPE_PLIST="$PLIST_DIR/com.justin.codexmeter.claude-plan-scrape.plist"
if [ -x "$SCRAPE_SCRIPT" ]; then
cat > "$SCRAPE_PLIST" <<PLIST
Expand All @@ -79,7 +79,7 @@ PLIST
<string>$PYTHON</string>
<string>$SCRAPE_SCRIPT</string>
</array>
<key>StartInterval</key><integer>60</integer>
<key>StartInterval</key><integer>15</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>$HOME/Library/Logs/codexmeter-claude-scrape.log</string>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/codexmeter-claude-scrape.err.log</string>
Expand Down
36 changes: 32 additions & 4 deletions ios/CodexMeterApp/CodexMeterApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ struct SettingsView: View {
@State private var selectedProvider: MeterViewModel.UsageProviderKind = .codex
@AppStorage("widget_usage_mode", store: UserDefaults.sharedGroup)
private var widgetUsageMode: String = "codex"
@AppStorage("display_framing", store: UserDefaults.sharedGroup)
private var displayFraming: String = "used"
@AppStorage("selected_pet", store: UserDefaults.sharedGroup)
private var selectedPet: String = "sukuna"
@State private var showPetPicker = false
Expand Down Expand Up @@ -389,6 +391,23 @@ struct SettingsView: View {
}
}

Section {
Picker("Percentages", selection: $displayFraming) {
Text("Used").tag("used")
Text("Left").tag("left")
}
.pickerStyle(.segmented)
.onChange(of: displayFraming) { _, newValue in
UserDefaults.standard.set(newValue, forKey: "display_framing")
UserDefaults.sharedGroup.set(newValue, forKey: "display_framing")
WidgetCenter.shared.reloadAllTimelines()
}
} header: {
Text("Display")
} footer: {
Text("\"Used\" matches claude.ai (fills as you consume). \"Left\" shows how much you have remaining.")
}

Section {
TextField("http://100.x.x.x:9595", text: $urlText)
.keyboardType(.URL)
Expand Down Expand Up @@ -699,13 +718,15 @@ struct UsageCard: View {
let title: String
let pct: Double
let resetMins: Int
@AppStorage("display_framing", store: UserDefaults.sharedGroup)
private var displayFraming: String = "used"

var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(title).font(.headline)
Spacer()
Text("\(Int(pct.rounded()))% remaining")
Text("\(Int(displayValue.rounded()))% \(displayFraming == "left" ? "left" : "used")")
.font(.title2).fontWeight(.bold)
.foregroundColor(barColor)
}
Expand All @@ -717,7 +738,7 @@ struct UsageCard: View {
.frame(height: 12)
RoundedRectangle(cornerRadius: 4)
.fill(barColor.gradient)
.frame(width: geometry.size.width * pct / 100, height: 12)
.frame(width: geometry.size.width * displayValue / 100, height: 12)
}
}
.frame(height: 12)
Expand All @@ -732,9 +753,16 @@ struct UsageCard: View {
.clipShape(RoundedRectangle(cornerRadius: 12))
}

var usedPct: Double { max(0, min(100, 100 - pct)) }
var leftPct: Double { max(0, min(100, pct)) }
/// What the big number AND the bar fill width represent.
var displayValue: Double { displayFraming == "left" ? leftPct : usedPct }

var barColor: Color {
if pct <= 20 { return .red }
if pct <= 50 { return .orange }
// Color stays keyed on usage pressure regardless of framing:
// red when heavily used, green when fresh.
if usedPct >= 80 { return .red }
if usedPct >= 50 { return .orange }
return .green
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ final class MeterViewModel: ObservableObject {

Task { await fetchUsage() }

fetchTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
fetchTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in await self?.fetchUsage() }
}

Expand Down
67 changes: 46 additions & 21 deletions ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct UsageProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<UsageEntry>) -> Void) {
fetchLiveEntries { entry in
let fallback = self.loadEntry() ?? UsageEntry(date: Date(), payload: UsagePayload(sourceName: "Codex", sessionPct: 0, weeklyPct: 0, sessionResetMins: -1, weeklyResetMins: -1, status: "Open app to sync", isOk: false, lastUpdated: nil))
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
let next = Calendar.current.date(byAdding: .minute, value: 5, to: Date()) ?? Date().addingTimeInterval(300)
completion(Timeline(entries: [entry ?? fallback], policy: .after(next)))
}
}
Expand Down Expand Up @@ -247,12 +247,35 @@ private func formatResetTime(mins: Int) -> String {
return "\(h)h \(m)m"
}

private func remainingColor(for pct: Int) -> Color {
if pct <= 20 { return Color.red }
if pct <= 50 { return Color.orange }
/// Convert a "remaining %" value from the daemon payload to the "used %" we
/// want to display so framings line up with claude.ai and the main app.
private func usedPct(from remainingPct: Int) -> Int {
max(0, min(100, 100 - remainingPct))
}

private func usedColor(forUsed pct: Int) -> Color {
if pct >= 80 { return Color.red }
if pct >= 50 { return Color.orange }
return Color.green
}

private enum DisplayFraming: String {
case used, left

static var current: DisplayFraming {
let raw = UserDefaults.sharedGroup.string(forKey: "display_framing") ?? "used"
return DisplayFraming(rawValue: raw) ?? .used
}

var suffix: String { self == .left ? "LEFT" : "USED" }
var lowerSuffix: String { self == .left ? "left" : "used" }
}

/// Number to render and bar width to use, given a "remaining %" from the payload.
private func displayNumber(remaining: Int) -> Int {
DisplayFraming.current == .left ? max(0, min(100, remaining)) : usedPct(from: remaining)
}

private func sourceIconName(_ sourceName: String) -> String {
sourceName.lowercased() == "claude" ? "ClaudeCodeIcon" : "CodexIcon"
}
Expand Down Expand Up @@ -282,12 +305,13 @@ struct AtomProgressBar: View {
.fill(barBg)
.padding(1)

if active && pct > 0 {
let clamped = min(max(pct, 0), 100)
let fillWidth = (geo.size.width - 2) * CGFloat(clamped) / 100.0
if active {
let used = usedPct(from: pct)
let display = displayNumber(remaining: pct)
let fillWidth = (geo.size.width - 2) * CGFloat(display) / 100.0
if fillWidth > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(remainingColor(for: pct))
.fill(usedColor(forUsed: used))
.frame(width: fillWidth)
.padding(1)
}
Expand Down Expand Up @@ -339,33 +363,33 @@ struct AtomWidgetStyleView: View {
} else {

// Session (Today)
Text(entry.isOk ? "\(formatResetTime(mins: entry.sessionResetMins).uppercased()) LEFT" : "TODAY")
Text(entry.isOk ? "5H \(DisplayFraming.current.suffix)" : "TODAY")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep widget window labels aligned with active provider

These labels are now hard-coded to 5H/7D for every successful entry, but AtomWidgetStyleView renders both Claude and Codex payloads. When the source is Codex, the widget still shows a 5-hour/7-day framing even though Codex session usage is daily, so the timeframe context presented to users is incorrect despite valid percentages. Please keep provider-specific labels (or derive them from the payload/reset window) instead of forcing Claude-specific text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex fit it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Implemented provider-specific widget window labels in UsageWidget.swift so Claude continues to show 5H/7D, while Codex now shows TODAY/WEEK, preventing incorrect timeframe framing on Codex payloads.
  • Replaced hardcoded timeframe text in AtomWidgetStyleView with the new helper functions at both session and weekly label callsites.
  • Committed the change as: Fix widget window labels by provider (commit 81a5e57).

Testing

  • cd /workspace/CodexMeter && git diff -- ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift
  • cd /workspace/CodexMeter && rg -n "sessionWindowLabel|weeklyWindowLabel|5H|7D|TODAY|WEEK" ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift
  • cd /workspace/CodexMeter && git commit -m "Fix widget window labels by provider"

View task →

.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundStyle(atomDim)
Text(entry.isOk ? "\(entry.sessionPct)%" : "--")

Text(entry.isOk ? "\(displayNumber(remaining: entry.sessionPct))%" : "--")
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundStyle(atomText)

AtomProgressBar(pct: entry.sessionPct, active: entry.isOk)
.frame(height: 10)
.padding(.vertical, 2)

Text(entry.isOk ? "reset \(formatResetTime(mins: entry.sessionResetMins))" : entry.status)
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(atomDim)
.lineLimit(1)

Spacer(minLength: 4)

// Weekly
HStack(alignment: .bottom, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
Text(entry.isOk ? "WK LEFT" : "WEEK")
Text(entry.isOk ? "7D \(DisplayFraming.current.suffix)" : "WEEK")
.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundStyle(atomDim)
Text(entry.isOk ? "\(entry.weeklyPct)%" : "--")

Text(entry.isOk ? "\(displayNumber(remaining: entry.weeklyPct))%" : "--")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(atomText)
}
Expand Down Expand Up @@ -479,8 +503,9 @@ private func relativeTime(_ date: Date) -> String {

struct AccessoryCircularView: View {
let entry: UsageEntry
private var shown: Int { displayNumber(remaining: entry.sessionPct) }
private var fraction: Double {
let value = Double(entry.sessionPct) / 100.0
let value = Double(shown) / 100.0
guard entry.isOk else { return max(0, value) }
return max(0.01, value)
}
Expand All @@ -493,7 +518,7 @@ struct AccessoryCircularView: View {
.stroke(.white, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -1) {
Text("\(entry.sessionPct)")
Text("\(shown)")
.font(.system(size: 17, weight: .bold, design: .rounded))
.monospacedDigit()
Text("%")
Expand All @@ -518,7 +543,7 @@ struct AccessoryRectangularView: View {
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
.tracking(1.2)
Text("\(entry.sessionPct)% left")
Text("\(displayNumber(remaining: entry.sessionPct))% \(DisplayFraming.current.lowerSuffix)")
.font(.system(size: 16, weight: .bold, design: .rounded))
.monospacedDigit()
if !entry.status.isEmpty {
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pytest>=7.0
ruff>=0.15
black>=24.0
zeroconf>=0.51
pycryptodome>=3.20
Loading
Loading