Skip to content

Redesign menu bar icon and provider icons, harden 429 handling#1

Closed
willwashburn wants to merge 11 commits into
mainfrom
claude/stoic-goodall-hebfmi
Closed

Redesign menu bar icon and provider icons, harden 429 handling#1
willwashburn wants to merge 11 commits into
mainfrom
claude/stoic-goodall-hebfmi

Conversation

@willwashburn

@willwashburn willwashburn commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Visual + resilience pass on the menu bar app.

Menu bar icon

  • Replaced the usage gauge with a flame that:
    • grows with the busiest window's usage (~11pt → ~17pt),
    • fills and turns red when that window is burning off its target pace (over the ideal burndown), and is otherwise green → yellow → orange by usage.
  • Rendered via ImageRenderer to a non-template NSImage so the menu bar preserves the color instead of flattening it to monochrome (the % text stays adaptive).

Provider icons (popover picker)

  • Switched to the official lobe-icons artwork: Claude renders its own coral mark; the provider previously drawn as the Codex blob now shows the OpenAI mark in white.
  • Icons now render full-color (self-colored SVGs as-is; monochrome marks tinted) rather than being grayed when unselected; selection is shown by the chip background.
  • Removed the refresh and quit buttons from the header.

Rate-limit (429) handling

  • The Anthropic usage endpoint was returning HTTP 429 (Cloudflare-edge throttle). The old code treated any non-200 as a hard error and wiped the panel.
  • Now a 429 maps to a transient .rateLimited state: the panel keeps the last good reading with a quiet notice and backs off exponentially (capped at 15 min) instead of hammering the endpoint. Manual refresh / provider switch bypass the backoff.

Housekeeping

  • Ignore node_modules/ and .npm-cache/.

Notes

  • ⚠️ Removing the quit button means there's no in-UI way to quit the app anymore (relaunch/Activity Monitor until a replacement affordance is added) — flag if you want a right-click menu or similar.
  • Color/flame rendering verified by rasterizing the bitmaps; confirm the live menu bar with ./build.sh && open dist/AgentLimit.app.

🤖 Generated with Claude Code

Review in cubic

claude and others added 5 commits June 16, 2026 21:30
Remove the Bun/React terminal dashboard and replace it with a native
SwiftUI menu bar app (MenuBarExtra + Swift Charts) that shows Claude and
Codex usage limits as burndown charts, matching the requested design.

- Reads existing CLI credentials (Claude Code keychain item, ~/.codex/auth.json)
- Fetches the same Anthropic OAuth usage and Codex usage endpoints
- Renders ideal-vs-actual burndown per window with over/under pace badges
- Persists usage samples to draw the real usage curve over time
- Auto-refreshes every 60s; provider picker for Codex/Claude
- build.sh assembles a menu-bar-only (LSUIElement) .app bundle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
Restore the proven monitor/1.0.0 User-Agent used by the prior working
implementation instead of a new value, in case the endpoint validates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
The Anthropic usage endpoint returns 6-digit fractional seconds with an
explicit offset (e.g. 2026-06-17T05:19:59.253508+00:00), which
ISO8601DateFormatter rejects. Fall back to stripping fractional seconds
so reset times parse and the burndown charts render instead of silently
degrading to a plain bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
- Replace the Claude/Codex dropdown with a segmented control of brand
  icons from lobe-icons (bundled SVGs, tinted as template images).
- Redesign burndown charts as cards: gradient gap fill, ringed current
  marker, "resets in" readout, and a header summary (% left + pace badge)
  so numbers no longer overlap the plot.
- Capitalize the plan label and present it as a pill.
- build.sh now bundles SwiftPM resource bundles into the .app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
Menu bar:
- Replace the usage gauge with a flame that grows with the busiest
  window's usage and fills + turns red when that window is burning off
  its target pace (over the ideal burndown). Rendered via ImageRenderer
  to a non-template NSImage so the color survives in the menu bar.

Popover:
- Use the official lobe-icons artwork: Claude renders its own coral mark,
  the provider formerly shown as the Codex blob now shows the OpenAI mark
  in white. Icons render full-color (self-colored as-is, monochrome marks
  tinted) instead of being grayed when unselected.
- Remove the refresh and quit buttons from the header.

Rate limiting:
- Treat HTTP 429 from the usage endpoints as a transient .rateLimited
  state: keep showing the last good reading with a quiet notice and back
  off exponentially instead of wiping the panel with a hard error.

Also ignore node_modules/ and .npm-cache/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request replaces the previous Node.js/Bun-based terminal dashboard with a native macOS menu bar application written in Swift and SwiftUI, displaying Claude and Codex usage limits as burndown charts. The code review identifies several critical and medium-severity issues in the new Swift implementation, including a potential runtime crash when modifying a dictionary during iteration, synchronous disk I/O blocking the main thread, a lack of protection against concurrent refreshes, rendering issues from using dynamic offsets as SwiftUI identifiers, thread-safety concerns in the icon cache, and a potential premature exit in the build script.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +62 to +71
private func pruneStaleWindows(reference: Date) {
for k in cache.keys {
let parts = k.split(separator: "|")
guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { continue }
let resetDate = Date(timeIntervalSince1970: ts)
if resetDate < reference.addingTimeInterval(-3600) {
cache.removeValue(forKey: k)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Modifying the cache dictionary while iterating over cache.keys will cause a runtime crash in Swift. You should collect the keys to remove first, and then perform the removal outside of the iteration.

Suggested change
private func pruneStaleWindows(reference: Date) {
for k in cache.keys {
let parts = k.split(separator: "|")
guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { continue }
let resetDate = Date(timeIntervalSince1970: ts)
if resetDate < reference.addingTimeInterval(-3600) {
cache.removeValue(forKey: k)
}
}
}
private func pruneStaleWindows(reference: Date) {
let keysToRemove = cache.keys.filter { k in
let parts = k.split(separator: "|")
guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { return false }
let resetDate = Date(timeIntervalSince1970: ts)
return resetDate < reference.addingTimeInterval(-3600)
}
for k in keysToRemove {
cache.removeValue(forKey: k)
}
}

Comment on lines +73 to +76
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Performing synchronous disk I/O (data.write(to:)) inside queue.sync blocks the calling thread. Since this is called from the @MainActor UsageViewModel on the main thread, it can cause UI stutters or hangs. Consider performing the write operation asynchronously on a background queue.

    private func persist() {
        guard let data = try? JSONEncoder().encode(cache) else { return }
        DispatchQueue.global(qos: .background).async {
            try? data.write(to: self.fileURL)
        }
    }

Comment on lines +57 to +60
func refresh(force: Bool = false) async {
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
isLoading = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is no guard to prevent concurrent refreshes if isLoading is already true. This can lead to multiple overlapping network requests and duplicate history records if the user triggers a manual refresh or switches providers quickly. Adding a check for isLoading prevents this.

Suggested change
func refresh(force: Bool = false) async {
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
isLoading = true
func refresh(force: Bool = false) async {
guard !isLoading else { return }
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
isLoading = true

Comment on lines +109 to +115
ForEach(Array(viewModel.charts.enumerated()), id: \.offset) { _, item in
if let data = item.data {
BurndownChartView(title: item.metric.name, data: data)
} else {
SimpleUsageRow(metric: item.metric)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using \.offset as the identifier in ForEach is an anti-pattern when the list can change dynamically (such as when switching providers). It can cause SwiftUI to reuse views incorrectly, leading to rendering glitches or animation bugs. Use the stable metric.id instead.

Suggested change
ForEach(Array(viewModel.charts.enumerated()), id: \.offset) { _, item in
if let data = item.data {
BurndownChartView(title: item.metric.name, data: data)
} else {
SimpleUsageRow(metric: item.metric)
}
}
ForEach(viewModel.charts, id: \.metric.id) { item in
if let data = item.data {
BurndownChartView(title: item.metric.name, data: data)
} else {
SimpleUsageRow(metric: item.metric)
}
}

/// Caches `NSImage`s loaded from the bundled SVG resources. A `nil` tint renders
/// the SVG's own colors; a non-nil tint recolors the opaque pixels to it while
/// preserving the glyph's transparency (for monochrome marks).
private final class BrandIconCache {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

BrandIconCache is a shared singleton accessed from the UI thread but is not thread-safe since its cache dictionary is mutated without synchronization. Marking it as @MainActor ensures safe access from the main thread.

Suggested change
private final class BrandIconCache {
@MainActor
private final class BrandIconCache {

Comment thread build.sh
Comment on lines +28 to +30
for bundle in "$BIN_PATH"/*.bundle; do
[ -e "$bundle" ] && cp -R "$bundle" "$APP_DIR/Contents/Resources/"
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Under set -e, if no .bundle files exist, the glob *.bundle evaluates literally, and [ -e "$bundle" ] returns false (exit code 1). This can cause the script to exit prematurely depending on the bash environment. Using a standard if statement is safer.

Suggested change
for bundle in "$BIN_PATH"/*.bundle; do
[ -e "$bundle" ] && cp -R "$bundle" "$APP_DIR/Contents/Resources/"
done
for bundle in "$BIN_PATH"/*.bundle; do
if [ -e "$bundle" ]; then
cp -R "$bundle" "$APP_DIR/Contents/Resources/"
fi
done

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 120cbd84ce

ℹ️ 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".

Comment thread App/Info.plist
Comment on lines +21 to +22
<key>LSUIElement</key>
<true/>

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 Add an in-app quit affordance

With LSUIElement enabled, macOS hides the app from the Dock and normal app menu, and the new MenuBarExtra content does not provide any Quit/NSApp.terminate action. Anyone who launches the installed background app therefore has no UI path to stop it other than killing it from Activity Monitor or the terminal, so keep a quit item somewhere in the menu bar UI before shipping this mode.

Useful? React with 👍 / 👎.

Use a warm orange→red ramp (red when off target) instead of the
green/yellow/orange scale — a green flame reads as contradictory. Also
remove the "%" text from the menu bar so only the flame shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The repository is rewritten from a Node.js/TypeScript terminal CLI (Bun/React/Ink) into a native macOS SwiftUI menu bar app. All TypeScript source, npm tooling, and CI publishing workflows are deleted. A new Swift package introduces credential loading, async provider fetching, persistent usage history, burndown chart data computation, and a full SwiftUI view hierarchy with a colored flame menu bar icon. New release automation handles macOS code signing, notarization, and DMG packaging.

Changes

Swift macOS App (AgentLimit)

Layer / File(s) Summary
Swift package, bundle config, and build tooling
Package.swift, build.sh, .gitignore, LICENSE
Package.swift declares the SwiftPM executable target (macOS 13+). build.sh assembles the .app bundle structure, copies the built executable, installs Info.plist metadata, and bundles resource icons and modules. .gitignore adds Swift/Xcode patterns. LICENSE includes MIT text.
Core data models and date parsing
Sources/AgentLimit/Models.swift
Defines ProviderName, ProviderStatusType, UsageMetric, ProviderStatus (with static constructors for unavailable/error/rateLimited states), and DateParsing with ISO-8601 fractional-seconds fallback via regex.
Credential loading from keychain and auth.json
Sources/AgentLimit/Credentials.swift
Credentials.claude() shells out to /usr/bin/security to read Claude OAuth tokens from the macOS keychain. Credentials.codex() reads ~/.codex/auth.json and decodes the JWT payload to extract chatgpt_plan_type.
Provider fetch logic and persistent usage history
Sources/AgentLimit/Providers.swift, Sources/AgentLimit/UsageHistory.swift
ClaudeProvider and CodexProvider authenticate, call provider APIs, map HTTP status codes (401/429/non-200) to ProviderStatus, and parse JSON into UsageMetric values. UsageHistoryStore is a serial-queue singleton that records, deduplicates (collapsing samples within 1 second), prunes stale windows, and persists samples to history.json.
Burndown chart data computation
Sources/AgentLimit/Burndown.swift
AreaPoint, LinePoint, and BurndownData carry actual-vs-ideal curve data and pacing metadata. BurndownBuilder.build() validates the metric window, computes ideal-remaining percentage over time, assembles the actual series from stored samples plus boundary points, and sets paceDelta and isOverPace.
UsageViewModel orchestration
Sources/AgentLimit/UsageViewModel.swift
@MainActor ObservableObject scheduling 60-second timer-driven refresh, provider-switch race protection via provider checks, rate-limit backoff with capped increasing delays, per-metric sample recording in UsageHistoryStore, BurndownData construction via BurndownBuilder, and computed menu-bar labels (headlineUsage, headlineOffTarget).
Brand icons and burndown chart views
Sources/AgentLimit/BrandIcon.swift, Sources/AgentLimit/BurndownChartView.swift
BrandIconCache loads and tint-rasterizes SVG provider icons at 64×64 with .sourceAtop blending; ProviderIcon renders cached SVG with SF Symbol fallback and configurable sizing. BurndownChartView renders a Charts card with ideal dashed line, AreaMark gap, actual LineMark, RuleMark at now, ringed PointMark, countdown header with pace-delta styling, and axis labels.
ContentView, SimpleUsageRow, and app entry
Sources/AgentLimit/ContentView.swift, Sources/AgentLimit/AgentLimitApp.swift
ContentView renders popover UI with provider icon segmented picker and dynamic subtitle, showing error/unavailable/notice/charts/loading content. SimpleUsageRow is a fallback displaying metric name, capped progress bar, and percentage. AgentLimitApp wires MenuBarExtra with ContentView and a MenuBarLabel that rasterizes a color-interpolated flame icon (orange→red via ImageRenderer) driven by headlineUsage and headlineOffTarget.
macOS release automation
.github/workflows/release.yml, release.sh
GitHub Actions workflow manually triggered to compute date-based version (YEAR.MONTH.COUNT), generate release notes via GitHub API, import Developer ID certificate, run release.sh in a retry loop (3 attempts), and publish GitHub Release as latest with DMG artifact. release.sh builds the app, stamps optional VERSION into Info.plist, code-signs with hardened runtime, submits to Apple notarization (supporting API key/profile/Apple ID auth), staples ticket, and creates arm64 UDZO DMG with /Applications symlink.
Icon generation tooling
scripts/generate-icon.swift, scripts/make-icon.sh
Swift script renders 1024×1024 macOS icon: clipped squircle background with gradient, centered gradient-filled "flame.fill" symbol, PNG output. Bash script invokes Swift generator, resizes to multiple dimensions via sips, assembles .iconset, and generates App/AppIcon.icns via iconutil.
README rewrite
README.md
Replaces CLI-oriented docs with native macOS app description: burndown chart behavior, under/over-pace shading, provider switching, 60-second auto-refresh with rate-limit backoff, credential sources (keychain and ~/.codex/auth.json), Swift build/run instructions, project directory tree, and detailed release workflow (date-based versioning, code signing, notarization, DMG publishing).

Sequence Diagram

sequenceDiagram
    actor User
    participant MenuBarLabel
    participant UsageViewModel
    participant ClaudeProvider
    participant CodexProvider
    participant UsageHistoryStore
    participant BurndownBuilder
    participant ContentView

    User->>MenuBarLabel: clicks menu bar icon
    MenuBarLabel->>UsageViewModel: observes headlineUsage / headlineOffTarget
    UsageViewModel->>ClaudeProvider: fetch()
    ClaudeProvider-->>UsageViewModel: ProviderStatus + [UsageMetric]
    UsageViewModel->>CodexProvider: fetch()
    CodexProvider-->>UsageViewModel: ProviderStatus + [UsageMetric]
    loop per metric
        UsageViewModel->>UsageHistoryStore: record(provider, metric)
        UsageHistoryStore-->>UsageViewModel: [UsageSample]
        UsageViewModel->>BurndownBuilder: build(metric, samples, now)
        BurndownBuilder-->>UsageViewModel: BurndownData?
    end
    UsageViewModel-->>MenuBarLabel: updated headlineUsage, headlineOffTarget
    UsageViewModel-->>ContentView: status, charts, lastUpdated, notice
    ContentView-->>User: renders popover with burndown charts
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hippity-hop, the terminal's gone,
A flame in the menu bar carries on.
Swift charts now burndown with orange and red,
Claude and Codex tracked overhead.
From keychain to notary, all signed and sealed,
No more npm—just Swifty appeal! 🔥

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main changes: menu bar icon redesign (flame visual), provider icon improvements, and HTTP 429 rate-limit handling enhancements.
Description check ✅ Passed The description thoroughly documents the visual changes, rate-limit resilience improvements, and housekeeping updates, directly corresponding to the changeset's main modifications across icon rendering, burndown charts, and provider handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/stoic-goodall-hebfmi

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OSV Scanner (2.3.8)

Error: ENOENT: no such file or directory, scandir '/inmem/1299/nsjail-214e79db-af9d-4e66-ac07-0dd24f0812d2/merged/.git/hooks'


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (1)
README.md (1)

41-41: ⚡ Quick win

Add language specifiers to fenced code blocks.

Markdown linting (MD040) requires language specifiers on all fenced code blocks. Lines 41, 48, 54, and 60 are missing them.

🔧 Proposed fixes
-```bash
+```bash

Apply ```bash to lines 41, 48, and 54.

For line 60 (the directory tree), use:

-```
+```text
 Package.swift                 Swift package manifest

Also applies to: 48-48, 54-54, 60-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 41, The fenced code blocks in the README.md file are
missing language specifiers, which violates markdown linting rules (MD040). Add
bash as the language specifier to the opening backticks on lines 41, 48, and 54
(change ``` to ```bash). For the directory tree section on line 60, add text as
the language specifier instead (change ``` to ```text).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 9: The README.md contains inaccurate claims that contradict the PR's
actual implementation. Remove or rephrase line 9 which claims the app "shows
your highest current usage as a percentage" since the PR objective states the
percentage text label is removed—update this to reflect that usage information
is conveyed only through the flame icon. Similarly, remove or rephrase line 20
which claims users can "refresh manually any time" since the PR removes the
refresh and quit buttons from the header and no refresh button exists in
ContentView.swift. Keep line 14's claim about the blue line color as it
accurately reflects BurndownChartView's lineColor property.

In `@Sources/AgentLimit/Burndown.swift`:
- Around line 60-63: The current code calculates remaining values by subtracting
percentage from 100, but does not clamp the input percentage values to the [0,
100] range first, which means negative percentages could produce remaining
values greater than 100. Fix this by clamping the percentage values to [0, 100]
before computing the remaining value in the expressions at lines 60 and 62 that
involve sample.percentage and metric.percentage respectively, as well as the
similar calculations at lines 80-81. Use min(max()) functions to clamp the
percentage value first, then subtract from 100 to get the remaining value.

In `@Sources/AgentLimit/ContentView.swift`:
- Around line 155-156: The metric percentage clamping in the ProgressView
initialization only clamps the upper bound with min(metric.percentage, 100),
allowing negative percentages to produce inconsistent results between the
progress bar and the label. Update line 155 to clamp both the lower and upper
bounds by wrapping the min function with max to ensure the value stays within
the 0...100 range, or use Swift's built-in clamped(to:) method on
metric.percentage to clamp it to a valid range before passing it to ProgressView
and the Text label on line 156.
- Around line 80-82: The `.rateLimited` case in ContentView.swift is only
displaying a noticeView banner and discarding the previously retained usage data
instead of showing them together. Modify the `.rateLimited` case handler to
display both the rate-limited notice banner and the retained charts/rows
content, similar to how other states handle combined notification and data
display. This ensures users can still view their usage information while being
informed about the rate-limited state.

In `@Sources/AgentLimit/Credentials.swift`:
- Around line 39-56: The keychainPassword(service:) function calls
process.waitUntilExit() without a timeout, causing credential refresh to block
indefinitely if the /usr/bin/security command hangs. Replace the unbounded
process.waitUntilExit() call with a timeout-aware mechanism using
DispatchSemaphore or a polling loop that monitors process termination with a
configurable timeout duration (e.g., a few seconds). If the timeout is exceeded
before the process terminates, terminate the process and return nil to allow the
application to continue rather than hanging indefinitely.

In `@Sources/AgentLimit/Providers.swift`:
- Around line 41-65: The code currently returns a healthy ProviderStatus even
when no metrics are successfully extracted, which silently masks schema changes
or API failures. After collecting metrics through the three window() function
calls (five_hour, seven_day, and seven_day_opus), add a check to ensure the
metrics array is not empty before returning the ProviderStatus. If metrics is
empty after all window() attempts, return a failure status instead of proceeding
with the successful response. Apply this same pattern to both provider status
parsing blocks mentioned in the comment (the claude provider section containing
the window() function and the second section referenced at lines 103-148).

In `@Sources/AgentLimit/UsageHistory.swift`:
- Around line 34-37: Metrics without a resetsAt value should not have their
samples persisted because they cannot be pruned and will accumulate
indefinitely. Add a guard condition at the beginning of all methods that persist
or save samples (including the key method and the methods referenced in lines
42-57 and 61-70) to check if the metric has a resetsAt value, and return early
or skip persistence if resetsAt is nil. This prevents generating keys with
"none" values that bypass timestamp-based pruning logic.
- Around line 73-75: The persist() method currently performs a non-atomic write
to fileURL that can result in a corrupted or truncated file if the app is
interrupted. Replace the current write(to:) call with write(to:options:) and
pass the .atomic option to ensure the file is written atomically. This
guarantees that either the entire file is written successfully or no changes are
applied, preventing data corruption from interrupted writes.

In `@Sources/AgentLimit/UsageViewModel.swift`:
- Around line 17-41: The timer property created in the start() method is never
invalidated, which can cause duplicate network activity if the UsageViewModel is
recreated. Add a deinit method to the UsageViewModel class that invalidates the
timer by calling invalidate() on it and setting it to nil. Also check the
location at line 125 mentioned in the comment to apply the same cleanup logic
there as well.
- Around line 57-63: The refresh() method has a race condition where slower
refresh calls can overwrite newer results in status, charts, and lastUpdated
properties. Add a tracking mechanism such as a timestamp or request identifier
captured before calling await provider.fetch() for each provider, then compare
this value before updating the state after the fetch completes to ensure only
the most recent refresh result is applied. This applies both to the main refresh
logic starting at Line 57 and the additional instances at Lines 98-101 that
update the same state properties.

---

Nitpick comments:
In `@README.md`:
- Line 41: The fenced code blocks in the README.md file are missing language
specifiers, which violates markdown linting rules (MD040). Add bash as the
language specifier to the opening backticks on lines 41, 48, and 54 (change ```
to ```bash). For the directory tree section on line 60, add text as the language
specifier instead (change ``` to ```text).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd78205f-505f-4449-91e7-6a25cb3ebcf0

📥 Commits

Reviewing files that changed from the base of the PR and between c02d976 and 0209eea.

⛔ Files ignored due to path filters (4)
  • Sources/AgentLimit/Resources/claude.svg is excluded by !**/*.svg
  • Sources/AgentLimit/Resources/openai.svg is excluded by !**/*.svg
  • bun.lock is excluded by !**/*.lock
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (38)
  • .github/workflows/publish.yml
  • .gitignore
  • AGENTS.md
  • App/Info.plist
  • CHANGELOG.md
  • LICENSE
  • Package.swift
  • README.md
  • Sources/AgentLimit/AgentLimitApp.swift
  • Sources/AgentLimit/BrandIcon.swift
  • Sources/AgentLimit/Burndown.swift
  • Sources/AgentLimit/BurndownChartView.swift
  • Sources/AgentLimit/ContentView.swift
  • Sources/AgentLimit/Credentials.swift
  • Sources/AgentLimit/Models.swift
  • Sources/AgentLimit/Providers.swift
  • Sources/AgentLimit/UsageHistory.swift
  • Sources/AgentLimit/UsageViewModel.swift
  • bin/cli.js
  • bin/cli.tsx
  • build.sh
  • package.json
  • src/App.tsx
  • src/components/Dashboard.tsx
  • src/components/Footer.tsx
  • src/components/Header.tsx
  • src/components/ProgressBar.tsx
  • src/components/ProviderCard.tsx
  • src/components/index.ts
  • src/index.tsx
  • src/providers/claude.ts
  • src/providers/codex.ts
  • src/providers/index.ts
  • src/providers/types.ts
  • src/utils/colors.ts
  • src/utils/keychain.ts
  • src/utils/time.ts
  • tsconfig.json
💤 Files with no reviewable changes (22)
  • AGENTS.md
  • src/components/Footer.tsx
  • tsconfig.json
  • src/providers/types.ts
  • src/utils/time.ts
  • src/providers/codex.ts
  • CHANGELOG.md
  • src/utils/colors.ts
  • package.json
  • src/utils/keychain.ts
  • src/components/ProviderCard.tsx
  • bin/cli.js
  • src/components/Dashboard.tsx
  • src/components/ProgressBar.tsx
  • src/index.tsx
  • .github/workflows/publish.yml
  • src/providers/claude.ts
  • src/components/Header.tsx
  • src/components/index.ts
  • bin/cli.tsx
  • src/App.tsx
  • src/providers/index.ts

Comment thread README.md Outdated
Comment on lines +60 to +63
series.append((sample.date, max(0, 100 - sample.percentage)))
}
series.append((now, max(0, 100 - metric.percentage)))
series.sort { $0.date < $1.date }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp percentage to [0, 100] before converting to remaining.

At Line 60/62/80, negative upstream percentages would produce remaining > 100, which can skew chart shading and pace comparisons. Clamp input percentage first, then compute remaining.

Suggested patch
+        func clampedPercentage(_ value: Double) -> Double {
+            min(100, max(0, value))
+        }
+
         var series: [(date: Date, remaining: Double)] = [(windowStart, 100)]
         for sample in samples where sample.date > windowStart && sample.date < now {
-            series.append((sample.date, max(0, 100 - sample.percentage)))
+            series.append((sample.date, 100 - clampedPercentage(sample.percentage)))
         }
-        series.append((now, max(0, 100 - metric.percentage)))
+        series.append((now, 100 - clampedPercentage(metric.percentage)))
@@
-            actualRemaining: max(0, 100 - metric.percentage),
+            actualRemaining: 100 - clampedPercentage(metric.percentage),

Also applies to: 80-81

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/Burndown.swift` around lines 60 - 63, The current code
calculates remaining values by subtracting percentage from 100, but does not
clamp the input percentage values to the [0, 100] range first, which means
negative percentages could produce remaining values greater than 100. Fix this
by clamping the percentage values to [0, 100] before computing the remaining
value in the expressions at lines 60 and 62 that involve sample.percentage and
metric.percentage respectively, as well as the similar calculations at lines
80-81. Use min(max()) functions to clamp the percentage value first, then
subtract from 100 to get the remaining value.

Comment on lines +80 to +82
case .rateLimited:
noticeView(status.message ?? "Rate-limited. Retrying shortly.")
default:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rate-limited state currently hides retained usage data

Line 80 routes .rateLimited to only noticeView, which drops the previously retained charts/rows instead of showing them with a banner as described in the PR objective.

Suggested fix
             case .rateLimited:
-                noticeView(status.message ?? "Rate-limited. Retrying shortly.")
+                VStack(alignment: .leading, spacing: 12) {
+                    noticeView(status.message ?? "Rate-limited. Retrying shortly.")
+                    if status.metrics.isEmpty {
+                        if viewModel.charts.isEmpty {
+                            messageView("No previous usage snapshot available yet.")
+                        } else {
+                            chartsView(plan: status.plan)
+                        }
+                    } else {
+                        chartsView(plan: status.plan)
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/ContentView.swift` around lines 80 - 82, The
`.rateLimited` case in ContentView.swift is only displaying a noticeView banner
and discarding the previously retained usage data instead of showing them
together. Modify the `.rateLimited` case handler to display both the
rate-limited notice banner and the retained charts/rows content, similar to how
other states handle combined notification and data display. This ensures users
can still view their usage information while being informed about the
rate-limited state.

Comment on lines +155 to +156
ProgressView(value: min(metric.percentage, 100), total: 100)
Text("\(Int(metric.percentage.rounded()))% used")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp progress input to a valid 0...100 range

Line 155 clamps only the upper bound. If a provider returns a negative percentage, the bar and label can diverge.

Suggested fix
-            ProgressView(value: min(metric.percentage, 100), total: 100)
+            ProgressView(value: max(0, min(metric.percentage, 100)), total: 100)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/ContentView.swift` around lines 155 - 156, The metric
percentage clamping in the ProgressView initialization only clamps the upper
bound with min(metric.percentage, 100), allowing negative percentages to produce
inconsistent results between the progress bar and the label. Update line 155 to
clamp both the lower and upper bounds by wrapping the min function with max to
ensure the value stays within the 0...100 range, or use Swift's built-in
clamped(to:) method on metric.percentage to clamp it to a valid range before
passing it to ProgressView and the Text label on line 156.

Comment on lines +39 to +56
private static func keychainPassword(service: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
process.arguments = ["find-generic-password", "-s", service, "-w"]

let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = Pipe()

do {
try process.run()
} catch {
return nil
}

let data = outPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify subprocess calls in credential loading are currently unbounded.
rg -n -C3 'Process\(|waitUntilExit|terminationHandler|timeout|find-generic-password' Sources/AgentLimit/Credentials.swift

Repository: AgentWorkforce/limit

Length of output: 826


Add a timeout for the keychain subprocess.

The subprocess wait at line 55 is unbounded; if /usr/bin/security hangs, credential refresh will block indefinitely.

Suggested fix
 private static func keychainPassword(service: String) -> String? {
     let process = Process()
     process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
     process.arguments = ["find-generic-password", "-s", service, "-w"]

     let outPipe = Pipe()
     process.standardOutput = outPipe
     process.standardError = Pipe()
+    let done = DispatchGroup()
+    done.enter()
+    process.terminationHandler = { _ in done.leave() }

     do {
         try process.run()
     } catch {
         return nil
     }

-    let data = outPipe.fileHandleForReading.readDataToEndOfFile()
-    process.waitUntilExit()
+    if done.wait(timeout: .now() + .seconds(5)) == .timedOut {
+        process.terminate()
+        return nil
+    }
+    let data = outPipe.fileHandleForReading.readDataToEndOfFile()
     guard process.terminationStatus == 0 else { return nil }

     let value = String(data: data, encoding: .utf8)?
         .trimmingCharacters(in: .whitespacesAndNewlines)
     return (value?.isEmpty == false) ? value : nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static func keychainPassword(service: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
process.arguments = ["find-generic-password", "-s", service, "-w"]
let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = Pipe()
do {
try process.run()
} catch {
return nil
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
private static func keychainPassword(service: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
process.arguments = ["find-generic-password", "-s", service, "-w"]
let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = Pipe()
let done = DispatchGroup()
done.enter()
process.terminationHandler = { _ in done.leave() }
do {
try process.run()
} catch {
return nil
}
if done.wait(timeout: .now() + .seconds(5)) == .timedOut {
process.terminate()
return nil
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else { return nil }
let value = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (value?.isEmpty == false) ? value : nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/Credentials.swift` around lines 39 - 56, The
keychainPassword(service:) function calls process.waitUntilExit() without a
timeout, causing credential refresh to block indefinitely if the
/usr/bin/security command hangs. Replace the unbounded process.waitUntilExit()
call with a timeout-aware mechanism using DispatchSemaphore or a polling loop
that monitors process termination with a configurable timeout duration (e.g., a
few seconds). If the timeout is exceeded before the process terminates,
terminate the process and return nil to allow the application to continue rather
than hanging indefinitely.

Comment on lines +41 to +65
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
var metrics: [UsageMetric] = []

func window(_ key: String, name: String, period: Double) -> UsageMetric? {
guard let w = json[key] as? [String: Any],
let utilization = (w["utilization"] as? NSNumber)?.doubleValue
else { return nil }
let resetsAt = (w["resets_at"] as? String).flatMap(DateParsing.date(from:))
return UsageMetric(name: name, percentage: utilization, resetsAt: resetsAt, periodSeconds: period)
}

if let m = window("five_hour", name: "5-hour", period: 5 * 3600) { metrics.append(m) }
if let m = window("seven_day", name: "Weekly", period: 7 * 24 * 3600) { metrics.append(m) }
if let m = window("seven_day_opus", name: "Opus", period: 7 * 24 * 3600), m.percentage > 0 {
metrics.append(m)
}

let maxUsage = metrics.map(\.percentage).max() ?? 0
return ProviderStatus(
provider: .claude,
status: maxUsage >= 80 ? .warning : .ok,
plan: credentials.subscriptionType ?? "Pro",
metrics: metrics,
message: nil
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when parsing yields no metrics.

Both providers can currently return a healthy status with metrics = [], which hides payload/schema breakage and silently drops the data pipeline.

Suggested fix
             if let m = window("seven_day_opus", name: "Opus", period: 7 * 24 * 3600), m.percentage > 0 {
                 metrics.append(m)
             }

+            guard !metrics.isEmpty else {
+                return .failure(.claude, "Unexpected API response format.")
+            }
+
             let maxUsage = metrics.map(\.percentage).max() ?? 0
             return ProviderStatus(
                 provider: .claude,
                 status: maxUsage >= 80 ? .warning : .ok,
                 plan: credentials.subscriptionType ?? "Pro",
@@
             if let secondary = rateLimit?["secondary_window"] as? [String: Any],
                let m = parse(secondary, primary: false) {
                 metrics.append(m)
             }

+            guard !metrics.isEmpty else {
+                return .failure(.codex, "Unexpected API response format.")
+            }
+
             let limitReached = (rateLimit?["limit_reached"] as? Bool) ?? false
             let maxUsage = metrics.map(\.percentage).max() ?? 0
             let planType = (json["plan_type"] as? String).map {
                 $0.prefix(1).uppercased() + $0.dropFirst()
             } ?? "Unknown"

Also applies to: 103-148

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/Providers.swift` around lines 41 - 65, The code currently
returns a healthy ProviderStatus even when no metrics are successfully
extracted, which silently masks schema changes or API failures. After collecting
metrics through the three window() function calls (five_hour, seven_day, and
seven_day_opus), add a check to ensure the metrics array is not empty before
returning the ProviderStatus. If metrics is empty after all window() attempts,
return a failure status instead of proceeding with the successful response.
Apply this same pattern to both provider status parsing blocks mentioned in the
comment (the claude provider section containing the window() function and the
second section referenced at lines 103-148).

Comment on lines +34 to +37
private func key(provider: ProviderName, metric: UsageMetric) -> String {
let reset = metric.resetsAt.map { String(Int($0.timeIntervalSince1970)) } ?? "none"
return "\(provider.rawValue)|\(metric.name)|\(reset)"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not persist samples for metrics without resetsAt.

These samples cannot be used by burndown computation and are never pruned (...|none keys bypass timestamp pruning), so history can grow without bound.

Suggested fix
 `@discardableResult`
 func record(provider: ProviderName, metric: UsageMetric, at date: Date = Date()) -> [UsageSample] {
+    guard metric.resetsAt != nil else { return [] }
     queue.sync {
         let k = key(provider: provider, metric: metric)
         var samples = cache[k] ?? []

Also applies to: 42-57, 61-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/UsageHistory.swift` around lines 34 - 37, Metrics without
a resetsAt value should not have their samples persisted because they cannot be
pruned and will accumulate indefinitely. Add a guard condition at the beginning
of all methods that persist or save samples (including the key method and the
methods referenced in lines 42-57 and 61-70) to check if the metric has a
resetsAt value, and return early or skip persistence if resetsAt is nil. This
prevents generating keys with "none" values that bypass timestamp-based pruning
logic.

Comment on lines +73 to +75
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Persist history.json atomically.

A non-atomic write can leave a truncated/corrupted file if the app is interrupted mid-write.

Suggested fix
 private func persist() {
     guard let data = try? JSONEncoder().encode(cache) else { return }
-    try? data.write(to: fileURL)
+    try? data.write(to: fileURL, options: .atomic)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL)
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL, options: .atomic)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/UsageHistory.swift` around lines 73 - 75, The persist()
method currently performs a non-atomic write to fileURL that can result in a
corrupted or truncated file if the app is interrupted. Replace the current
write(to:) call with write(to:options:) and pass the .atomic option to ensure
the file is written atomically. This guarantees that either the entire file is
written successfully or no changes are applied, preventing data corruption from
interrupted writes.

Comment on lines +17 to +41
private var timer: Timer?
/// While set, scheduled (non-forced) refreshes are skipped to let a 429 clear.
private var backoffUntil: Date?
private var consecutiveRateLimits = 0
private let providers: [ProviderName: UsageProvider] = [
.claude: ClaudeProvider(),
.codex: CodexProvider(),
]

init() {
if let raw = UserDefaults.standard.string(forKey: "selectedProvider"),
let provider = ProviderName(rawValue: raw) {
selectedProvider = provider
} else {
selectedProvider = .codex
}
start()
}

private func start() {
Task { await refresh(force: true) }
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
Task { await self?.refresh() }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Invalidate the repeating timer on teardown.

At Line 17-41, Timer is created but never invalidated. If this model is recreated, old timers can continue firing and duplicate network activity.

Suggested patch
 final class UsageViewModel: ObservableObject {
@@
     private var timer: Timer?
@@
+    deinit {
+        timer?.invalidate()
+    }
+
     private func start() {
         Task { await refresh(force: true) }
         timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
             Task { await self?.refresh() }
         }
     }

Also applies to: 125-125

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/UsageViewModel.swift` around lines 17 - 41, The timer
property created in the start() method is never invalidated, which can cause
duplicate network activity if the UsageViewModel is recreated. Add a deinit
method to the UsageViewModel class that invalidates the timer by calling
invalidate() on it and setting it to nil. Also check the location at line 125
mentioned in the comment to apply the same cleanup logic there as well.

Comment on lines +57 to +63
func refresh(force: Bool = false) async {
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
isLoading = true
let result = await provider.fetch()
let now = Date()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Prevent out-of-order refresh results from overwriting newer state.

At Line 57+, multiple refresh() calls can overlap across await provider.fetch(). A slower older call can later overwrite status/charts/lastUpdated from a newer call for the same provider.

Suggested direction
 final class UsageViewModel: ObservableObject {
+    private var activeRefreshToken = UUID()
@@
     func refresh(force: Bool = false) async {
+        let token = UUID()
+        activeRefreshToken = token
         guard let provider = providers[selectedProvider] else { return }
         if !force, let until = backoffUntil, Date() < until { return }
         isLoading = true
         let result = await provider.fetch()
+        guard token == activeRefreshToken else { return }
         let now = Date()
@@
-        status = result
-        charts = built
-        lastUpdated = now
-        isLoading = false
+        status = result
+        charts = built
+        lastUpdated = now
+        isLoading = false
     }
 }

Also applies to: 98-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AgentLimit/UsageViewModel.swift` around lines 57 - 63, The refresh()
method has a race condition where slower refresh calls can overwrite newer
results in status, charts, and lastUpdated properties. Add a tracking mechanism
such as a timestamp or request identifier captured before calling await
provider.fetch() for each provider, then compare this value before updating the
state after the fetch completes to ensure only the most recent refresh result is
applied. This applies both to the main refresh logic starting at Line 57 and the
additional instances at Lines 98-101 that update the same state properties.

willwashburn and others added 5 commits June 17, 2026 08:24
A usage-varying icon width shifted the menu bar layout on every refresh.
Pin the flame at 15pt; usage is still conveyed by the orange→red color
and the fill/outline pace state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- release.sh: build → codesign (hardened runtime) → notarytool → staple →
  DMG, driven entirely by environment variables so it runs locally or in CI.
- .github/workflows/release.yml: on a v* tag, import the Developer ID cert
  from secrets, build/sign/notarize, and publish the DMG to GitHub Releases.
- README: Install (download the DMG) and Releasing (required secrets +
  tagging) sections; refresh stale menu-bar/icon/refresh descriptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the release pipeline to mirror ../pear so one set of Apple secrets
covers both repos:

- Manual workflow_dispatch trigger (no tag push needed).
- Date-based version YEAR.MONTH.N computed from the release count, stamped
  into Info.plist transiently (never committed).
- Auto-generated changelog from commits since the last tag.
- Notarize via App Store Connect API key (APPLE_API_KEY_*), sign via
  CSC_LINK/CSC_KEY_PASSWORD — the same secret names Pear uses. Throwaway CI
  keychain password is generated per-run; signing identity auto-detected.
- Stable DMG name AgentLimit-arm64.dmg, published as latest so
  releases/latest/download/AgentLimit-arm64.dmg always resolves.
- Publish to a draft then flip live, with a 3x notarization retry loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- App icon: a dark squircle with an orange→red gradient flame, matching
  the menu bar icon. Generated by scripts/make-icon.sh (renders the flame
  via scripts/generate-icon.swift, then sips + iconutil → App/AppIcon.icns).
  build.sh copies it into the bundle; Info.plist references it.
- Quit: since the app is menu-bar-only (no Dock icon or app menu), add a
  subtle "Quit" button in the popover footer, bound to ⌘Q.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the footer Quit button with an invisible ⌘Q handler in the
popover background, per request. Same behavior (quits while the popover
is open) without the on-screen button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
scripts/generate-icon.swift (1)

34-36: ⚡ Quick win

Replace force-unwrap/try! write path with explicit error handling.

The chained !/try! can terminate the script without actionable diagnostics during packaging. Prefer guarded serialization and do/catch with clear stderr output and non-zero exit.

Suggested patch
 let out = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/icon_1024.png"
-let png = NSBitmapImageRep(data: canvas.tiffRepresentation!)!.representation(using: .png, properties: [:])!
-try! png.write(to: URL(fileURLWithPath: out))
+guard
+  let tiff = canvas.tiffRepresentation,
+  let rep = NSBitmapImageRep(data: tiff),
+  let png = rep.representation(using: .png, properties: [:])
+else {
+  fputs("Failed to rasterize icon image data.\n", stderr)
+  exit(1)
+}
+do {
+  try png.write(to: URL(fileURLWithPath: out))
+} catch {
+  fputs("Failed to write icon to \(out): \(error)\n", stderr)
+  exit(1)
+}
 print("wrote \(out)")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/generate-icon.swift` around lines 34 - 36, Replace the force-unwrap
chains and try! statement in the canvas serialization and file writing section
with proper error handling. Specifically, wrap the NSBitmapImageRep data
conversion and the png.write call in a do/catch block that captures and prints
detailed error information to stderr before exiting with a non-zero status code.
Remove the chained force unwraps (!) from the NSBitmapImageRep initialization
and representation method calls, and replace the try! on png.write with a
throwing try statement inside the do block to enable actionable error
diagnostics during packaging.
.github/workflows/release.yml (1)

17-20: ⚡ Quick win

Add persist-credentials: false to the checkout action.

The checkout action persists credentials by default, which could be exposed if artifacts containing the .git directory are uploaded or if subsequent steps access git config. Adding persist-credentials: false is a low-effort security hardening.

       - name: Checkout
         uses: actions/checkout@v4
         with:
           fetch-depth: 0
+          persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 17 - 20, The checkout action in
the release workflow is missing the persist-credentials security parameter. Add
persist-credentials: false to the with section of the actions/checkout@v4 step
to prevent git credentials from being persisted in the repository, which reduces
the risk of credential exposure if artifacts containing the .git directory are
uploaded or accessed in subsequent workflow steps.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 44-45: The DMG download link uses a relative path
`../../releases/...` that walks above the repository root, causing it to break
on GitHub. Replace the relative path with an absolute repository-root relative
URL by using `/releases/latest/download/AgentLimit-arm64.dmg` instead, which
will correctly resolve on GitHub regardless of where the README is viewed from.

---

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 17-20: The checkout action in the release workflow is missing the
persist-credentials security parameter. Add persist-credentials: false to the
with section of the actions/checkout@v4 step to prevent git credentials from
being persisted in the repository, which reduces the risk of credential exposure
if artifacts containing the .git directory are uploaded or accessed in
subsequent workflow steps.

In `@scripts/generate-icon.swift`:
- Around line 34-36: Replace the force-unwrap chains and try! statement in the
canvas serialization and file writing section with proper error handling.
Specifically, wrap the NSBitmapImageRep data conversion and the png.write call
in a do/catch block that captures and prints detailed error information to
stderr before exiting with a non-zero status code. Remove the chained force
unwraps (!) from the NSBitmapImageRep initialization and representation method
calls, and replace the try! on png.write with a throwing try statement inside
the do block to enable actionable error diagnostics during packaging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6bf1db1a-96b5-4b29-b0e6-3afd517b7253

📥 Commits

Reviewing files that changed from the base of the PR and between 0209eea and f1233da.

📒 Files selected for processing (10)
  • .github/workflows/release.yml
  • App/AppIcon.icns
  • App/Info.plist
  • README.md
  • Sources/AgentLimit/AgentLimitApp.swift
  • Sources/AgentLimit/ContentView.swift
  • build.sh
  • release.sh
  • scripts/generate-icon.swift
  • scripts/make-icon.sh
🚧 Files skipped from review as they are similar to previous changes (3)
  • App/Info.plist
  • build.sh
  • Sources/AgentLimit/ContentView.swift

Comment thread README.md
Comment on lines +44 to +45
> **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)**
> (Apple Silicon)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the DMG download link.

../../releases/... walks above the repo root here, so the link will break on GitHub. Use a repo-root relative link instead.

🔧 Suggested fix
-> **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)**
+> **[⬇ AgentLimit-arm64.dmg](releases/latest/download/AgentLimit-arm64.dmg)**
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)**
> (Apple Silicon)
> **[⬇ AgentLimit-arm64.dmg](releases/latest/download/AgentLimit-arm64.dmg)**
> (Apple Silicon)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 44 - 45, The DMG download link uses a relative path
`../../releases/...` that walks above the repository root, causing it to break
on GitHub. Replace the relative path with an absolute repository-root relative
URL by using `/releases/latest/download/AgentLimit-arm64.dmg` instead, which
will correctly resolve on GitHub regardless of where the README is viewed from.

@willwashburn

Copy link
Copy Markdown
Member Author

Superseded by AgentWorkforce/burn#478 — the app now lives in the burn monorepo at apps/macos and reads spend from the ledger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants