Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,12 @@ Codex works the same way through a different door: stack-nudge asks the local `c

Polls every 60 seconds while the full panel is open, and otherwise every 5 minutes by default (configurable via Settings → Usage → "Poll frequency"). The collapsed widget counts as background, so it polls at your configured frequency rather than the faster open-panel rate. Opening the panel syncs immediately if the last one is over a minute old. On the Usage tab: `r` triggers a manual sync, `p` pauses/resumes the poller.

Claude, Codex, and Antigravity each appear in the tab's client list when they have quota to show; `↑`/`↓` switch between them. **The compact widget's gauge follows whatever you select here** — pick Codex in the Usage tab and the pill's rings, hover legend, and reset countdown all switch to Codex, with the client name shown in the legend on hover. All three are read on the same poll tick, so switching costs nothing. The selection is in-memory and resets to the first connected client on relaunch.
Claude, Codex, Antigravity and pi each appear in the tab's client list when they have something to show; `↑`/`↓` switch between them. **The compact widget's gauge follows whatever you select here** — pick Codex in the Usage tab and the pill's rings, hover legend, and reset countdown all switch to Codex, with the client name shown in the legend on hover. All four are read on the same poll tick, so switching costs nothing. The selection is in-memory and resets to the first connected client on relaunch.

For Claude and Codex the two rings are the 5-hour and weekly windows — though Codex reports the window length per limit rather than a fixed pair, and on some accounts publishes only a weekly one, so its Usage tab headings are named from what it actually reports. Antigravity reports neither — it publishes one window per model plus a monthly credit pool — so its inner ring shows whichever model is closest to its limit and the outer ring shows monthly prompt credits. Gemini CLI has no usage counter: unlike the others it writes no rate-limit data to disk and serves no local endpoint, so there's nothing to read.

**pi is the one client whose bars are not a quota.** It enforces none: its API models bill per token against your own keys, and its local models cost nothing. What its page tracks is a fair-usage budget you set yourself, one daily token allowance for API models and one for local models, with the week being seven days of it. The page lists one row per model, the same bar, reset line and pace warning as the other clients, each measured against the allowance for its kind and naming it (*"of 1.5M API budget"*), and the tab labels it `Budget` where the others show a subscription tier. `W` toggles the page between today and this week. The windows are calendar ones rather than rolling, which is what gives the reset time and the ahead-of-pace warning something to measure against, and the widget's rings follow whichever model is closest to its budget in each. Tokens are counted the way the history graph's "no cache reads" metric counts them, so resuming a long session doesn't burn the budget by replaying itself, and a turn counts as local when pi priced it at zero. Set the two allowances in Settings → Usage (`STACKNUDGE_PI_API_BUDGET` / `STACKNUDGE_PI_LOCAL_BUDGET`, tokens per day, `Off` to drop that kind's models from the page). Going over is reported rather than clamped: that is the only thing a budget you set for yourself can usefully tell you.

#### Threshold-crossing notifications

When any quota tier reaches your configured threshold, stack-nudge fires a banner — *"Weekly quota at 85% — resets May 17"* — once per period per tier, so you get a heads-up before hitting the cap. Configure in Settings → Usage:
Expand Down
398 changes: 398 additions & 0 deletions Tests/StackNudgePanelCoreTests/PiUsageTests.swift

Large diffs are not rendered by default.

38 changes: 36 additions & 2 deletions Tests/StackNudgePanelCoreTests/WidgetQuotaTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,46 @@ final class WidgetQuotaTests: XCTestCase {
private func make(_ client: UsageClient?,
claude: QuotaSnapshot? = nil,
codex: CodexQuotaSnapshot? = nil,
agy: AntigravityQuotaSnapshot? = nil) -> WidgetQuota {
WidgetQuota.make(client: client, claude: claude, codex: codex, antigravity: agy)
agy: AntigravityQuotaSnapshot? = nil,
pi: PiQuotaSnapshot? = nil) -> WidgetQuota {
WidgetQuota.make(client: client, claude: claude, codex: codex, antigravity: agy, pi: pi)
}

private func piSnapshot(today: [Double], week: [Double]) -> PiQuotaSnapshot {
func models(_ used: [Double], _ duration: TimeInterval) -> [PiModelUsage] {
used.enumerated().map { index, value in
PiModelUsage(key: UsageModelKey(provider: "p", model: "m\(index)", isLocal: false),
name: "m\(index)", tokens: 1,
tier: QuotaTier(utilization: value, resetsAt: Date().addingTimeInterval(duration),
windowLength: duration))
}
}
return PiQuotaSnapshot(today: models(today, 86400), thisWeek: models(week, 7 * 86400), budget: .fallback)
}

// MARK: - Per-client ring mapping

// Like Antigravity: whichever model is closest to its budget takes the ring.
func test_pi_closestModelTakesEachRing() {
let q = make(.pi, pi: piSnapshot(today: [12, 62, 4], week: [18, 2]))
XCTAssertEqual(q.short?.utilization, 62)
XCTAssertEqual(q.long?.utilization, 18)
XCTAssertEqual(q.shortLabel, "1d")
XCTAssertEqual(q.longLabel, "7d")
}

func test_pi_weekOnlyUsageLeavesTheInnerRingEmpty() {
let q = make(.pi, pi: piSnapshot(today: [], week: [9]))
XCTAssertNil(q.short)
XCTAssertEqual(q.long?.utilization, 9)
}

// A budget is the user's own, so passing it is the point of the row.
func test_pi_overBudgetIsNotClamped() {
let q = make(.pi, pi: piSnapshot(today: [140], week: [30]))
XCTAssertEqual(q.short?.utilization, 140)
}

func test_claude_mapsFiveHourAndSevenDay() {
let q = make(.claude, claude: claudeSnapshot(five: 40, seven: 12))
XCTAssertEqual(q.short?.utilization, 40)
Expand Down
10 changes: 10 additions & 0 deletions notify.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@
# Default: tag
#STACKNUDGE_SUBAGENT_NUDGES=off

# Fair-usage budget for pi, in tokens per day, per lane. pi enforces no quota
# of its own — API models bill per token against your keys, local models are
# free — so these are your own allowances and nothing cuts you off at them. The
# Usage tab bars today and this week (seven days' worth) against them and names
# the number on every row. 0 drops that lane's rows. Also settable in
# Settings → Usage, which writes back here.
# Defaults: 1500000 API, 500000 local
#STACKNUDGE_PI_API_BUDGET=1500000
#STACKNUDGE_PI_LOCAL_BUDGET=500000

# Log a debug line (to the launchd log) explaining voice decisions — useful
# when a notification played silently and you want to know why.
# Default: false
Expand Down
19 changes: 19 additions & 0 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
private let claudeCliQuotaProbe = ClaudeCliQuotaProbe()
private let codexQuotaProbe = CodexQuotaProbe()
private let antigravityUsageProbe = AntigravityUsageProbe()
// Lazy because it shares the nav's history store rather than opening its
// own, so pi's transcripts are parsed once for the graph and the budget.
private lazy var piUsageProbe = PiUsageProbe(store: nav.usageStore)
private var quotaTimer: Timer?
// Last outcome derived per repo+branch, alongside the git values it was
// derived from, so refreshOutcomes can skip re-deriving what hasn't moved.
Expand Down Expand Up @@ -1308,6 +1311,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
nav.refreshOutcomes = { [weak self] in self?.refreshOutcomes() }
nav.refreshPullRequests = { [weak self] in self?.refreshPullRequests() }
nav.refreshPullRequestsNow = { [weak self] in self?.refreshPullRequestsNow() }
nav.refreshPiBudget = { [weak self] in self?.refreshPiUsage() }
nav.startGithubSignIn = { [weak self] in self?.startGithubSignIn() }
nav.cancelGithubSignIn = { [weak self] in self?.cancelGithubSignIn() }

Expand Down Expand Up @@ -2100,6 +2104,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
self.nav.quotaErrors[.antigravity] = nil
}
}
refreshPiUsage()
}

// Pi budget, read from pi's own transcripts with no network and no CLI.
// Unlike the probes above there's no failure to surface: the denominators
// are the user's own (see PiBudget). Also run on a budget change in
// Settings, which is why it sits outside runQuotaProbe.
private func refreshPiUsage() {
guard quotaTrackingEnabled else { return }
piUsageProbe.fetch(budget: nav.piBudget) { [weak self] snapshot in
self?.nav.applyPiSnapshot(snapshot)
}
}

// Public hook for the Usage tab's "Sync now" keystroke.
Expand Down Expand Up @@ -4211,6 +4227,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
case KeyCode.wKey where nav.usagePane == .history:
// Re-buckets cached entries; no rescan, so it lands instantly.
nav.cycleUsageWindow()
case KeyCode.wKey where nav.selectedUsageClient == .pi:
// Both windows are already in the snapshot, so this is a repaint.
nav.cyclePiWindow()
case KeyCode.rKey:
syncQuotaNow()
case KeyCode.pKey:
Expand Down
76 changes: 70 additions & 6 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ enum SettingsRow: Hashable {
case soundEnabled, agentDoneSound, permissionSound
case voiceEnabled, voice, voiceSpeed, speakHotkey, downloadVoiceModel
case quotaTracking, quotaAlerts, alertThreshold, pollFrequency, contextAlert, showRemaining
case piApiBudget, piLocalBudget
case githubLinks, hideShipped, disconnectGithub
case historyPerSession
case editPhrases, checkPermissions, openConfig, releaseNotes, checkUpdates, uninstall, quit
Expand Down Expand Up @@ -211,7 +212,7 @@ extension SettingsRow: CaseIterable {
.soundEnabled, .agentDoneSound, .permissionSound,
.voiceEnabled, .voice, .voiceSpeed, .speakHotkey, .downloadVoiceModel,
.quotaTracking, .quotaAlerts, .alertThreshold, .pollFrequency,
.contextAlert, .showRemaining,
.contextAlert, .showRemaining, .piApiBudget, .piLocalBudget,
.githubLinks, .hideShipped, .disconnectGithub,
.historyPerSession,
.editPhrases, .checkPermissions, .openConfig, .releaseNotes,
Expand Down Expand Up @@ -424,6 +425,49 @@ final class PanelNav: ObservableObject {
// Antigravity (agy) usage from the running CLI's loopback RPC, populated by
// AntigravityUsageProbe — the agy analogue of `quota`/`codexQuota`.
@Published var antigravityQuota: AntigravityQuotaSnapshot? { didSet { widgetQuotaCache = nil } }
// Pi's self-imposed budget, populated by PiUsageProbe from pi's own
// transcripts. Not a provider quota — see PiBudget.
@Published var piQuota: PiQuotaSnapshot? { didSet { widgetQuotaCache = nil } }
@Published var piApiBudgetDaily: Int = PiBudget.apiDailyDefault
@Published var piLocalBudgetDaily: Int = PiBudget.localDailyDefault

var piBudget: PiBudget {
PiBudget(apiDaily: piApiBudgetDaily, localDaily: piLocalBudgetDaily)
}
// Which window the pi page shows; W toggles it. In-memory, like usageWindow.
@Published var piWindow: PiWindow = .today

func cyclePiWindow() {
let windows = PiWindow.allCases
let index = windows.firstIndex(of: piWindow) ?? 0
piWindow = windows[(index + 1) % windows.count]
}

// Wired by PanelController to re-read pi's usage. Fired on a budget change
// so the Usage tab isn't left on the old denominator until the next poll.
var refreshPiBudget: (() -> Void)?

func setPiBudget(apiDaily: Int, localDaily: Int) {
piApiBudgetDaily = apiDaily
piLocalBudgetDaily = localDaily
refreshPiBudget?()
}

// nil clears the row rather than holding the last snapshot. pi is read from
// local disk, so there's no dropped tick to ride out: nil means no usage in
// either window, or both lanes budgeted off.
func applyPiSnapshot(_ snapshot: PiQuotaSnapshot?) {
piQuota = snapshot
guard snapshot != nil else { return }
quotaLastUpdated = Date()
quotaUpdatedAt[.pi] = Date()
}

static func stepBudget(_ current: Int, forward: Bool) -> Int {
let list = PiBudget.dailyOptions
let index = list.firstIndex(of: current) ?? 0
return list[forward ? (index + 1) % list.count : (index - 1 + list.count) % list.count]
}
// Bumped by PanelController after a handoff is upserted into the ledger so
// the Tickets tab (OutcomesView) and its tab-strip count re-read the
// in-memory HandoffLedger and reflect the new session live. The ledger
Expand Down Expand Up @@ -673,7 +717,10 @@ final class PanelNav: ObservableObject {
@Published var usageWindow: UsageWindow = .widest
// Long-lived parse cache. Owned per-nav rather than global so nothing leaks
// between instances, and so tests can drive a clean one.
private let usageStore = UsageHistoryStore()
// Shared with PiUsageProbe so pi's transcripts are parsed once for both the
// graph and the budget. The store retains the widest span any caller asks
// for, so the graph's 24h refresh can't evict the budget's week.
let usageStore = UsageHistoryStore()
// Replayed transcript history for the selected client, keyed so switching
// client doesn't show another client's numbers while a scan is in flight.
@Published var usageSeries: UsageSeries?
Expand All @@ -699,6 +746,7 @@ final class PanelNav: ObservableObject {
case .claude: return quota?.hasTier == true
case .codex: return codexQuota?.hasTier == true
case .antigravity: return antigravityQuota?.hasTier == true
case .pi: return piQuota?.hasTier == true
}
}

Expand Down Expand Up @@ -747,7 +795,8 @@ final class PanelNav: ObservableObject {
let value = WidgetQuota.make(client: selectedUsageClient,
claude: quota,
codex: codexQuota,
antigravity: antigravityQuota)
antigravity: antigravityQuota,
pi: piQuota)
widgetQuotaCache = value
return value
}
Expand Down Expand Up @@ -834,7 +883,7 @@ final class PanelNav: ObservableObject {
// pressing W during an in-flight scan would otherwise have its
// re-bucket silently reverted by this completion, leaving the header
// claiming one window while the chart showed another.
store.refresh(source: source, retaining: .widest)
store.refresh(source: source, retaining: UsageWindow.widest.seconds)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Pure computation over entries already in memory (~1 ms), so
Expand Down Expand Up @@ -1221,7 +1270,8 @@ final class PanelNav: ObservableObject {
.widgetContent, .mascot, .theme]
case .usage:
return [.quotaTracking, .quotaAlerts, .alertThreshold,
.pollFrequency, .contextAlert, .showRemaining]
.pollFrequency, .contextAlert, .showRemaining,
.piApiBudget, .piLocalBudget]
case .integrations:
return [.slackPaste, .slackIdentity, .slackTest,
.slackEnabled, .slackIdle, .slackDetail, .slackStop,
Expand Down Expand Up @@ -1385,6 +1435,12 @@ final class PanelNav: ObservableObject {
// Same coercion for poll interval — snap to nearest valid option.
let rawPoll = Int(config["STACKNUDGE_USAGE_POLL_MIN"] ?? "") ?? 5
quotaPollMinutes = Self.quotaPollMinuteOptions.min(by: { abs($0 - rawPoll) < abs($1 - rawPoll) }) ?? 5
let rawPiApi = Int(config["STACKNUDGE_PI_API_BUDGET"] ?? "") ?? PiBudget.apiDailyDefault
piApiBudgetDaily = PiBudget.dailyOptions.min(by: { abs($0 - rawPiApi) < abs($1 - rawPiApi) })
?? PiBudget.apiDailyDefault
let rawPiLocal = Int(config["STACKNUDGE_PI_LOCAL_BUDGET"] ?? "") ?? PiBudget.localDailyDefault
piLocalBudgetDaily = PiBudget.dailyOptions.min(by: { abs($0 - rawPiLocal) < abs($1 - rawPiLocal) })
?? PiBudget.localDailyDefault
let rawCtx = Int(config["STACKNUDGE_CONTEXT_ALERT_THRESHOLD"] ?? "") ?? 0
contextAlertThresholdK = Self.contextAlertThresholdOptions.min(by: { abs($0 - rawCtx) < abs($1 - rawCtx) }) ?? 0
eventHistoryEnabled = ConfigFile.bool(config, "STACKNUDGE_EVENT_HISTORY", default: true)
Expand Down Expand Up @@ -1699,7 +1755,7 @@ final class PanelNav: ObservableObject {
.soundEnabled, .agentDoneSound, .permissionSound,
.voiceEnabled, .voice, .voiceSpeed, .downloadVoiceModel,
.quotaTracking, .quotaAlerts, .alertThreshold, .pollFrequency,
.contextAlert, .showRemaining,
.contextAlert, .showRemaining, .piApiBudget, .piLocalBudget,
.githubLinks, .hideShipped,
.historyPerSession, .eventHistory,
.slackEnabled, .slackIdle, .slackDetail, .slackStop:
Expand Down Expand Up @@ -1904,6 +1960,14 @@ final class PanelNav: ObservableObject {
let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count
quotaAlertThreshold = list[next]
ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold))
case .piApiBudget:
setPiBudget(apiDaily: Self.stepBudget(piApiBudgetDaily, forward: forward),
localDaily: piLocalBudgetDaily)
ConfigFile.write(key: "STACKNUDGE_PI_API_BUDGET", value: String(piApiBudgetDaily))
case .piLocalBudget:
setPiBudget(apiDaily: piApiBudgetDaily,
localDaily: Self.stepBudget(piLocalBudgetDaily, forward: forward))
ConfigFile.write(key: "STACKNUDGE_PI_LOCAL_BUDGET", value: String(piLocalBudgetDaily))
case .pollFrequency:
let list = Self.quotaPollMinuteOptions
let idx = list.firstIndex(of: quotaPollMinutes) ?? 2
Expand Down
Loading
Loading