Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### Added
- sub2api: add group-key usage with daily, weekly, and monthly quotas, multi-account switching, wallet balance, and expiry details. Thanks @weirdo-adam!

### Changed
- Refresh: make Adaptive the fresh-install default and offer an explicit one-time choice before using local Codex or Claude activity to cap unconstrained idle delays at 5 minutes. Existing installations without a stored cadence keep the old 5-minute fallback, existing Adaptive users do not scan before consent, and every valid stored cadence remains unchanged. Local session scans are bounded, and remote Agent Sessions stay opt-in.

### Fixed
- Startup: load persisted plan-utilization history away from the main thread so mature histories no longer delay app launch. Thanks @Yuxin-Qiao!
- Provider cleanup: prevent in-flight usage, status, token-cost, and cached-hydration work from republishing stale state after a provider is disabled, unavailable, or re-enabled. Thanks @Yuxin-Qiao!
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,16 @@ show an incident indicator.
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
- Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection.
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
- Fresh installs default to Adaptive refresh. Existing users keep every valid stored choice, while legacy unset or
invalid preferences resolve to 5 minutes. Manual and fixed 1m, 2m, 5m, 15m, and 30m alternatives remain available.
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex`, `claude`, or `both` for local cost usage); macOS and Linux CLI builds available.
- WidgetKit widgets for supported providers.
- Localized app and website with a shared 21-language catalog, automatic website detection, persistent pickers, and RTL support.
- Optional session quota notifications and weekly-reset confetti.
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).

## Privacy note
Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, provider config files, local JSONL logs) when the related features are enabled. Provider tokens and token-account settings live in the CodexBar config file with restrictive file permissions. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12).
Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, provider config files, local JSONL logs) when the related features are enabled. Adaptive refresh asks before inspecting the running-process list (including command lines) to identify Codex/Claude and reading known session metadata. Declining keeps menu-based Adaptive behavior without local activity scans, and existing Adaptive users do not scan before making that choice. When allowed with Agent Sessions hidden, CodexBar retains only the latest activity time and discards session paths and identities. Provider tokens and token-account settings live in the CodexBar config file with restrictive file permissions. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12).

## macOS permissions (why they’re needed)
- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers. If you don’t grant it, use another supported browser, manual cookies/API keys, OAuth, or CLI/local sources where that provider supports them.
Expand Down
16 changes: 16 additions & 0 deletions Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ package struct AdaptiveRefreshPolicyCore: Sendable {
package struct Input: Sendable, Equatable {
package let now: Date
package let lastMenuOpenAt: Date?
package let lastCodingActivityAt: Date?
package let lowPowerModeEnabled: Bool
package let thermalPressure: ThermalPressure

package init(
now: Date,
lastMenuOpenAt: Date?,
lastCodingActivityAt: Date? = nil,
lowPowerModeEnabled: Bool,
thermalPressure: ThermalPressure)
{
self.now = now
self.lastMenuOpenAt = lastMenuOpenAt
self.lastCodingActivityAt = lastCodingActivityAt
self.lowPowerModeEnabled = lowPowerModeEnabled
self.thermalPressure = thermalPressure
}
Expand All @@ -30,6 +33,7 @@ package struct AdaptiveRefreshPolicyCore: Sendable {

package enum Reason: String, Sendable, Equatable {
case recentInteraction
case codingActivity
case warm
case idle
case longIdle
Expand All @@ -49,12 +53,14 @@ package struct AdaptiveRefreshPolicyCore: Sendable {
private static let recentInteractionThreshold: TimeInterval = 5 * 60
private static let warmThreshold: TimeInterval = 60 * 60
private static let idleThreshold: TimeInterval = 4 * 60 * 60
private static let codingActivityThreshold: TimeInterval = 5 * 60

private static let recentInteractionDelay: Duration = .seconds(2 * 60)
private static let warmDelay: Duration = .seconds(5 * 60)
private static let idleDelay: Duration = .seconds(15 * 60)
private static let longIdleDelay: Duration = .seconds(30 * 60)
private static let constrainedDelay: Duration = .seconds(30 * 60)
private static let codingActivityDelayCap: Duration = .seconds(5 * 60)

/// Representative cadence for consumers that need one interval but cannot access live state.
package static let nominalIntervalForHeuristics: TimeInterval = 5 * 60
Expand All @@ -66,6 +72,16 @@ package struct AdaptiveRefreshPolicyCore: Sendable {
return Decision(delay: Self.constrainedDelay, reason: .constrained)
}

let baseDecision = self.menuActivityDecision(for: input)
guard let lastCodingActivityAt = input.lastCodingActivityAt,
input.now.timeIntervalSince(lastCodingActivityAt) < Self.codingActivityThreshold,
baseDecision.delay > Self.codingActivityDelayCap
else { return baseDecision }

return Decision(delay: Self.codingActivityDelayCap, reason: .codingActivity)
}

private func menuActivityDecision(for input: Input) -> Decision {
guard let lastMenuOpenAt = input.lastMenuOpenAt else {
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
}
Expand Down
17 changes: 13 additions & 4 deletions Sources/AdaptiveReplayCLI/CLIArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

enum ReplayPolicyName: String, CaseIterable, Sendable {
case adaptive
case adaptiveActivity = "adaptive-activity"
case adaptiveMenuOnly = "adaptive-menu-only"
case fixed2Minutes = "fixed-2m"
case fixed5Minutes = "fixed-5m"
case fixed15Minutes = "fixed-15m"
Expand All @@ -14,8 +14,8 @@ enum ReplayPolicyName: String, CaseIterable, Sendable {
switch self {
case .adaptive:
AdaptiveReplayPolicy()
case .adaptiveActivity:
CodingActivityAdaptivePolicy()
case .adaptiveMenuOnly:
MenuOnlyAdaptivePolicy()
case .fixed2Minutes:
FixedIntervalPolicy(minutes: 2)
case .fixed5Minutes:
Expand All @@ -32,6 +32,15 @@ enum ReplayPolicyName: String, CaseIterable, Sendable {
static var expectedValues: String {
allCases.map(\.rawValue).joined(separator: ", ")
}

static func parse(_ rawValue: String) -> Self? {
// 0.42.1 shipped this name for the activity-aware candidate. That candidate is now the
// production adaptive policy, so retain the CLI spelling as a non-enumerated alias.
if rawValue == "adaptive-activity" {
return .adaptive
}
return Self(rawValue: rawValue)
}
}

enum CLIArguments {
Expand Down Expand Up @@ -72,7 +81,7 @@ enum CLIArguments {
index += 1
guard index < arguments.count else { return .invalid(message: "--policy requires a value") }
let rawPolicyName = arguments[index]
guard let policyName = ReplayPolicyName(rawValue: rawPolicyName) else {
guard let policyName = ReplayPolicyName.parse(rawPolicyName) else {
return .invalid(
message: "unknown policy '\(rawPolicyName)' (expected: \(ReplayPolicyName.expectedValues))")
}
Expand Down
7 changes: 4 additions & 3 deletions Sources/AdaptiveReplayCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,16 @@ enum AdaptiveReplayCLI {
counterfactual policy events; recorded live schedule evaluations are audited separately.

Policies:
adaptive The shared production adaptive policy. Advances on menu-open interactions,
same as UsageStore.noteMenuOpened(at:).
adaptive-activity Experimental adaptive table capped at 5m after observed coding activity.
adaptive Production policy. Uses menu opens and optional local coding-activity fields.
adaptive-menu-only Historical baseline that ignores observed coding activity.
fixed-2m Fixed 2 minute cadence. Unaffected by menu-open interactions.
fixed-5m Fixed 5 minute cadence.
fixed-15m Fixed 15 minute cadence.
fixed-30m Fixed 30 minute cadence.
manual Never refreshes (degenerate floor).

The released adaptive-activity spelling remains a deprecated alias for adaptive.

Defaults to comparing all seven policies when --policy is omitted.

Options:
Expand Down
11 changes: 11 additions & 0 deletions Sources/AdaptiveReplayKit/BaselinePolicies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@ public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable {
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
now: input.now,
lastMenuOpenAt: input.lastMenuOpenAt,
lastCodingActivityAt: input.lastCodingActivityAt,
lowPowerModeEnabled: input.lowPowerModeEnabled,
thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal))
return ReplayPolicyDecision(
delaySeconds: TimeInterval(decision.delay.components.seconds),
reason: decision.reason.rawValue)
}

func decide(_ input: ReplayPolicyInput, lastCodingActivityAt: Date?) -> ReplayPolicyDecision {
let normalized = ReplayPolicyInput(
now: input.now,
lastMenuOpenAt: input.lastMenuOpenAt,
lastCodingActivityAt: lastCodingActivityAt,
lowPowerModeEnabled: input.lowPowerModeEnabled,
thermalState: input.thermalState)
return self.decide(normalized)
}
}

/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to
Expand Down
27 changes: 0 additions & 27 deletions Sources/AdaptiveReplayKit/CandidatePolicies.swift

This file was deleted.

16 changes: 16 additions & 0 deletions Sources/AdaptiveReplayKit/HistoricalPolicies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// Historical menu-only adaptive policy retained as a replay baseline after coding activity moved
/// into the production policy. It calls the same canonical core with that one signal omitted.
public struct MenuOnlyAdaptivePolicy: ReplayPolicy, Sendable {
public let name = "adaptive-menu-only"
public let advancesOnInteraction = true

private let base = AdaptiveReplayPolicy()

public init() {}

public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
self.base.decide(input, lastCodingActivityAt: nil)
}
}
6 changes: 3 additions & 3 deletions Sources/AdaptiveReplayKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the
The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal,
Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan
Codex or Claude transcript directories, write trace files, call providers, or change the production
refresh policy. Trace capture and lifecycle management are deliberately outside this PR; callers
refresh policy at runtime. Trace capture and lifecycle management are deliberately outside this tool; callers
provide an existing trace path to the CLI.

Optional activity fields in the trace schema are inputs only. The replay kit never discovers or
Expand All @@ -20,8 +20,8 @@ collects them. Old records without those fields continue to decode.
- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is
available for exploratory work that explicitly accepts skipped malformed records.
- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`,
`BaselinePolicies.swift`, and `CandidatePolicies.swift` provide replay adapters, fixed/manual
baselines, and the replay-only activity candidate.
`BaselinePolicies.swift`, and `HistoricalPolicies.swift` provide the production adapter, fixed/manual
baselines, and the historical menu-only Adaptive baseline.
- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open
staleness, interaction advances, and constrained-state compliance.
- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodexBar/AdaptiveActivityConsentPresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import AppKit

@MainActor
enum AdaptiveActivityConsentPresenter {
private static var isPresenting = false

@discardableResult
static func presentIfNeeded(settings: SettingsStore) -> Bool {
guard !SettingsStore.isRunningTests,
!self.isPresenting,
settings.shouldRequestAdaptiveActivityScanConsent
else { return false }

self.isPresenting = true
defer { self.isPresenting = false }

let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = L("adaptive_activity_consent_title")
alert.informativeText = L("adaptive_activity_consent_message")
alert.addButton(withTitle: L("adaptive_activity_consent_allow"))
let declineButton = alert.addButton(withTitle: L("adaptive_activity_consent_decline"))
declineButton.keyEquivalent = "\u{1B}"

NSApp.activate(ignoringOtherApps: true)
settings.adaptiveActivityScanConsent = alert.runModal() == .alertFirstButtonReturn ? .allowed : .declined
return true
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/AdaptiveRefreshPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct AdaptiveRefreshPolicy: Sendable {
struct Input: Sendable, Equatable {
let now: Date
let lastMenuOpenAt: Date?
let lastCodingActivityAt: Date?
let lowPowerModeEnabled: Bool
let thermalState: ProcessInfo.ThermalState
}
Expand All @@ -25,6 +26,7 @@ struct AdaptiveRefreshPolicy: Sendable {
AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
now: input.now,
lastMenuOpenAt: input.lastMenuOpenAt,
lastCodingActivityAt: input.lastCodingActivityAt,
lowPowerModeEnabled: input.lowPowerModeEnabled,
thermalPressure: Self.isConstrained(input.thermalState) ? .constrained : .nominal))
}
Expand Down
Loading