Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 fallback for missing or invalid preferences and use recent local Codex or Claude activity to cap unconstrained idle delays at 5 minutes. Existing installations without a stored cadence move from the old implicit 5-minute fallback; 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,15 @@ 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).
- Adaptive refresh by default, with manual and fixed 1m, 2m, 5m, 15m, and 30m alternatives.
- 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 uses a bounded local Codex/Claude session-metadata scan; with Agent Sessions hidden, it 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
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
67 changes: 57 additions & 10 deletions Sources/CodexBar/AgentSessionsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class AgentSessionsStore {
private(set) var localSessions: [AgentSession] = []
private(set) var remoteHosts: [RemoteSessionHostResult] = []
private(set) var lastUpdatedAt: Date?
private(set) var latestLocalActivityAt: Date?

init(
settings: SettingsStore,
Expand All @@ -59,6 +60,29 @@ final class AgentSessionsStore {
self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count }
}

/// Adaptive refresh uses the local metadata signal only. Remote sessions remain behind the
/// explicit Agent Sessions setting because they can involve Tailscale discovery and SSH.
var localMonitoringEnabled: Bool {
self.settings.agentSessionsEnabled || self.settings.refreshFrequency == .adaptive
}

nonisolated static func latestActivityAt(in sessions: [AgentSession]) -> Date? {
sessions.compactMap(\.lastActivityAt).max()
}

nonisolated static func shouldScanLocally(
agentSessionsEnabled: Bool,
adaptiveRefreshEnabled: Bool,
lowPowerModeEnabled: Bool,
thermalState: ProcessInfo.ThermalState) -> Bool
{
if agentSessionsEnabled {
return true
}
guard adaptiveRefreshEnabled, !lowPowerModeEnabled else { return false }
return thermalState != .serious && thermalState != .critical
}

func start() {
guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return }
self.localRefreshTask = Task { [weak self] in
Expand All @@ -82,26 +106,37 @@ final class AgentSessionsStore {
self.remoteRefreshTask = nil
}

func settingsDidChange() {
self.remoteRefreshGate.settingsDidChange()
guard self.settings.agentSessionsEnabled else {
func settingsDidChange(remoteConfigurationChanged: Bool = true) {
if remoteConfigurationChanged {
self.remoteRefreshGate.settingsDidChange()
}
if !self.settings.agentSessionsEnabled {
// Adaptive keeps only the timestamp signal. Retained session paths and identities
// remain scoped to the explicitly enabled Agent Sessions UI.
self.localSessions = []
self.remoteHosts = []
}
guard self.localMonitoringEnabled else {
self.latestLocalActivityAt = nil
self.onUpdate?()
return
}
guard !SettingsStore.isRunningTests else { return }
Task { [weak self] in
await self?.refreshLocal()
await self?.refreshRemote()
if remoteConfigurationChanged, self?.settings.agentSessionsEnabled == true {
await self?.refreshRemote()
}
}
}

func refreshOnMenuOpen() {
guard self.settings.agentSessionsEnabled, !SettingsStore.isRunningTests else { return }
guard self.localMonitoringEnabled, !SettingsStore.isRunningTests else { return }
Task { [weak self] in
await self?.refreshLocal()
await self?.refreshRemote()
if self?.settings.agentSessionsEnabled == true {
await self?.refreshRemote()
}
}
}

Expand All @@ -116,13 +151,25 @@ final class AgentSessionsStore {
}

private func refreshLocal() async {
guard self.settings.agentSessionsEnabled, !self.localRefreshInFlight else { return }
guard self.localMonitoringEnabled, !self.localRefreshInFlight else { return }
let processInfo = ProcessInfo.processInfo
guard Self.shouldScanLocally(
agentSessionsEnabled: self.settings.agentSessionsEnabled,
adaptiveRefreshEnabled: self.settings.refreshFrequency == .adaptive,
lowPowerModeEnabled: processInfo.isLowPowerModeEnabled,
thermalState: processInfo.thermalState)
else { return }
self.localRefreshInFlight = true
let sessions = await self.localScanner.scan()
self.localRefreshInFlight = false
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
self.localSessions = sessions
self.lastUpdatedAt = Date()
guard !Task.isCancelled, self.localMonitoringEnabled else { return }
self.applyLocalScanResult(sessions)
Comment on lines 164 to +168

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 04df269. LocalAgentSessionScanner.scan is now @concurrent, so the synchronous ps/lsof work and transcript parsing do not inherit a MainActor caller. I kept structured concurrency instead of using a detached task, which preserves cancellation, priority, and task-local values. The 37 repair-focused tests pass and make check is clean.

}

func applyLocalScanResult(_ sessions: [AgentSession], updatedAt: Date = Date()) {
self.latestLocalActivityAt = Self.latestActivityAt(in: sessions)
self.localSessions = self.settings.agentSessionsEnabled ? sessions : []
self.lastUpdatedAt = updatedAt
self.onUpdate?()
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ extension SettingsStore {
private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState {
let refreshDefault = userDefaults.string(forKey: "refreshFrequency")
.flatMap(RefreshFrequency.init(rawValue:))
let refreshFrequency = refreshDefault ?? .fiveMinutes
let refreshFrequency = refreshDefault ?? .adaptive
if Self.isRunningTests, refreshDefault == nil {
userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency")
}
Expand Down
Loading
Loading