diff --git a/CHANGELOG.md b/CHANGELOG.md index 218cecc7f1..a6213751ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! diff --git a/README.md b/README.md index af8c6cc36d..28f09f8b50 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ 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. @@ -158,7 +159,7 @@ show an incident indicator. - 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. diff --git a/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift b/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift index bf5140eb49..b95629ca73 100644 --- a/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift +++ b/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift @@ -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 } @@ -30,6 +33,7 @@ package struct AdaptiveRefreshPolicyCore: Sendable { package enum Reason: String, Sendable, Equatable { case recentInteraction + case codingActivity case warm case idle case longIdle @@ -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 @@ -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) } diff --git a/Sources/AdaptiveReplayCLI/CLIArguments.swift b/Sources/AdaptiveReplayCLI/CLIArguments.swift index 1f351ff552..7d214fe77c 100644 --- a/Sources/AdaptiveReplayCLI/CLIArguments.swift +++ b/Sources/AdaptiveReplayCLI/CLIArguments.swift @@ -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" @@ -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: @@ -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 { @@ -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))") } diff --git a/Sources/AdaptiveReplayCLI/main.swift b/Sources/AdaptiveReplayCLI/main.swift index 30259dd902..5fd21aeada 100644 --- a/Sources/AdaptiveReplayCLI/main.swift +++ b/Sources/AdaptiveReplayCLI/main.swift @@ -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: diff --git a/Sources/AdaptiveReplayKit/BaselinePolicies.swift b/Sources/AdaptiveReplayKit/BaselinePolicies.swift index ac73d2b5cd..a4ade0ad90 100644 --- a/Sources/AdaptiveReplayKit/BaselinePolicies.swift +++ b/Sources/AdaptiveReplayKit/BaselinePolicies.swift @@ -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 diff --git a/Sources/AdaptiveReplayKit/CandidatePolicies.swift b/Sources/AdaptiveReplayKit/CandidatePolicies.swift deleted file mode 100644 index ba4f4e6169..0000000000 --- a/Sources/AdaptiveReplayKit/CandidatePolicies.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -/// Replay-only candidate used to test whether stat-only coding activity can close the accepted -/// "active work is never slower than five minutes" gap. It is not a production policy approval. -public struct CodingActivityAdaptivePolicy: ReplayPolicy, Sendable { - public let name = "adaptive-activity" - public let advancesOnInteraction = true - - private let base = AdaptiveReplayPolicy() - private static let activeThreshold: TimeInterval = 5 * 60 - private static let activeDelayCap: TimeInterval = 5 * 60 - - public init() {} - - public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision { - let baseDecision = self.base.decide(input) - guard !input.isConstrained, - let activityAge = input.codingActivityAgeSeconds, - activityAge < Self.activeThreshold, - let baseDelay = baseDecision.delaySeconds, - baseDelay > Self.activeDelayCap - else { - return baseDecision - } - return ReplayPolicyDecision(delaySeconds: Self.activeDelayCap, reason: "codingActivity") - } -} diff --git a/Sources/AdaptiveReplayKit/HistoricalPolicies.swift b/Sources/AdaptiveReplayKit/HistoricalPolicies.swift new file mode 100644 index 0000000000..18a4266222 --- /dev/null +++ b/Sources/AdaptiveReplayKit/HistoricalPolicies.swift @@ -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) + } +} diff --git a/Sources/AdaptiveReplayKit/README.md b/Sources/AdaptiveReplayKit/README.md index e3cfac744b..4b8facbe71 100644 --- a/Sources/AdaptiveReplayKit/README.md +++ b/Sources/AdaptiveReplayKit/README.md @@ -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 @@ -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 diff --git a/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift b/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift new file mode 100644 index 0000000000..187dfc467a --- /dev/null +++ b/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift @@ -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 + } +} diff --git a/Sources/CodexBar/AdaptiveRefreshPolicy.swift b/Sources/CodexBar/AdaptiveRefreshPolicy.swift index 7d6bfebee6..fe60617669 100644 --- a/Sources/CodexBar/AdaptiveRefreshPolicy.swift +++ b/Sources/CodexBar/AdaptiveRefreshPolicy.swift @@ -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 } @@ -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)) } diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift index c340b8ea76..9a999e568f 100644 --- a/Sources/CodexBar/AgentSessionsStore.swift +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -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, @@ -59,6 +60,30 @@ final class AgentSessionsStore { self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count } } + /// Adaptive refresh uses local metadata only after explicit consent. Remote sessions remain + /// behind the Agent Sessions setting because they can involve Tailscale discovery and SSH. + var localMonitoringEnabled: Bool { + self.settings.agentSessionsEnabled || + (self.settings.refreshFrequency == .adaptive && self.settings.adaptiveActivityScanningEnabled) + } + + nonisolated static func latestActivityAt(in sessions: [AgentSession]) -> Date? { + sessions.compactMap(\.lastActivityAt).max() + } + + nonisolated static func shouldScanLocally( + agentSessionsEnabled: Bool, + adaptiveActivityScanningEnabled: Bool, + lowPowerModeEnabled: Bool, + thermalState: ProcessInfo.ThermalState) -> Bool + { + if agentSessionsEnabled { + return true + } + guard adaptiveActivityScanningEnabled, !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 @@ -82,26 +107,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() + } } } @@ -116,13 +152,26 @@ 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, + adaptiveActivityScanningEnabled: self.settings.refreshFrequency == .adaptive && + self.settings.adaptiveActivityScanningEnabled, + 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) + } + + func applyLocalScanResult(_ sessions: [AgentSession], updatedAt: Date = Date()) { + self.latestLocalActivityAt = Self.latestActivityAt(in: sessions) + self.localSessions = self.settings.agentSessionsEnabled ? sessions : [] + self.lastUpdatedAt = updatedAt self.onUpdate?() } diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index ae6078a3dc..ce13123ff5 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -382,12 +382,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - AppNotifications.shared.requestAuthorizationOnStartup() self.memoryPressureMonitor.start() #if DEBUG self.installDebugMemoryPressureObserverIfNeeded() #endif self.ensureStatusController() + Task { @MainActor [weak self] in + await Task.yield() + guard let settings = self?.settings else { return } + let presentedAdaptiveConsent = AdaptiveActivityConsentPresenter.presentIfNeeded(settings: settings) + // Avoid stacking two permission prompts on first launch. Notification authorization + // remains startup-driven and is requested on the next launch after this one-time choice. + if !presentedAdaptiveConsent { + AppNotifications.shared.requestAuthorizationOnStartup() + } + } KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in // KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop. MainActor.assumeIsolated { @@ -494,7 +503,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func ensureStatusController() { - if self.statusController != nil { return } + if self.statusController != nil { + return + } if let store, let settings, diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index fd2f373447..ab650349d0 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -119,6 +119,18 @@ struct GeneralPane: View { label: { Text(L("refresh_interval_title")) }, optionLabel: { option in Text(option.label) }) + if self.settings.refreshFrequency == .adaptive, !self.settings.agentSessionsEnabled { + Toggle(isOn: Binding( + get: { self.settings.adaptiveActivityScanningEnabled }, + set: { enabled in + self.settings.adaptiveActivityScanConsent = enabled ? .allowed : .declined + })) { + SettingsRowLabel( + L("adaptive_activity_scan_title"), + subtitle: L("adaptive_activity_scan_subtitle")) + } + } + Toggle(L("refresh_on_open_title"), isOn: self.$settings.refreshAllProvidersOnMenuOpen) Toggle(isOn: self.$settings.statusChecksEnabled) { diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 1cac33ab4a..6238e39d5b 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -108,6 +108,10 @@ struct PreferencesView: View { .onChange(of: self.settings.debugMenuEnabled) { _, _ in self.ensureValidSelection() } + .onChange(of: self.settings.shouldRequestAdaptiveActivityScanConsent) { _, shouldRequest in + guard shouldRequest else { return } + AdaptiveActivityConsentPresenter.presentIfNeeded(settings: self.settings) + } } @ViewBuilder diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 91cef1e6a5..99ad883e94 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1187,3 +1187,9 @@ "Show Codex Spark usage" = "عرض استخدام Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "يعرض صفوف حصة Codex Spark في القائمة ومعاينة المزوّد. يتطلب تفعيل «عرض الاعتمادات + الاستخدام الإضافي» في إعدادات العرض."; "Scroll to see more models" = "مرر لرؤية المزيد من النماذج"; +"adaptive_activity_consent_title" = "السماح بالتحديث المستجيب للنشاط؟"; +"adaptive_activity_consent_message" = "يمكن للتحديث التكيفي فحص قائمة العمليات المحلية قيد التشغيل، بما في ذلك أسطر الأوامر، للتعرّف على Codex وClaude، ثم قراءة بيانات تعريف الجلسات المعروفة كل 30 ثانية أثناء البرمجة. عند إيقاف Agent Sessions، لا يستخدم CodexBar سوى وقت أحدث نشاط في الذاكرة ويتجاهل مسارات الجلسات وهوياتها. لا تُرسل بيانات النشاط هذه إلى أي مكان، ويظل الاكتشاف عن بُعد وSSH متوقفين. إذا رفضت، فسيظل الوضع التكيفي يعتمد على نشاط القائمة ويتخطى عمليات فحص النشاط المحلي. يمكنك تغيير هذا لاحقًا من الإعدادات > عام."; +"adaptive_activity_consent_allow" = "السماح بالنشاط المحلي"; +"adaptive_activity_consent_decline" = "استخدام نشاط القائمة فقط"; +"adaptive_activity_scan_title" = "استخدام نشاط البرمجة المحلي"; +"adaptive_activity_scan_subtitle" = "يفحص قائمة العمليات قيد التشغيل وبيانات تعريف جلسات Codex/Claude المعروفة كل 30 ثانية. لا يُستخدم سوى وقت أحدث نشاط؛ ولا تُرسل بيانات النشاط هذه، ويظل الاكتشاف عن بُعد وSSH متوقفين."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 77d03ca853..19d58c9d22 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1186,3 +1186,9 @@ "Show Codex Spark usage" = "Mostreu l'ús de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostreu les files de quota de Codex Spark al menú i a la previsualització del proveïdor. Cal activar «Mostreu crèdits + ús addicional» a la configuració de Pantalla."; "Scroll to see more models" = "Desplaceu-vos per veure més models"; +"adaptive_activity_consent_title" = "Voleu permetre l’actualització segons l’activitat?"; +"adaptive_activity_consent_message" = "L’actualització adaptativa pot inspeccionar la llista de processos locals en execució, incloses les línies d’ordres, per identificar Codex i Claude, i després llegir les metadades de sessions conegudes cada 30 segons mentre programeu. Amb Agent Sessions desactivat, CodexBar només conserva a la memòria l’hora de l’activitat més recent i descarta les rutes i identitats de les sessions. Aquestes dades d’activitat no s’envien enlloc, i la detecció remota i SSH continuen desactivats. Si ho rebutgeu, el mode Adaptatiu continuarà utilitzant l’activitat del menú i ometrà les exploracions d’activitat local. Ho podeu canviar més endavant a Configuració > General."; +"adaptive_activity_consent_allow" = "Permeteu l’activitat local"; +"adaptive_activity_consent_decline" = "Utilitzeu només l’activitat del menú"; +"adaptive_activity_scan_title" = "Utilitza l’activitat de programació local"; +"adaptive_activity_scan_subtitle" = "Inspecciona cada 30 segons la llista de processos en execució i les metadades de sessions conegudes de Codex/Claude. Només s’utilitza l’hora de l’activitat més recent; aquestes dades d’activitat no s’envien, i la detecció remota i SSH continuen desactivats."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 2e28eec56f..a68aaf91fa 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1180,3 +1180,9 @@ "Show Codex Spark usage" = "Codex-Spark-Nutzung anzeigen"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Zeigt die Codex-Spark-Kontingentzeilen im Menü und in der Anbietervorschau an. Erfordert, dass „Credits + zusätzliche Nutzung anzeigen“ in den Anzeigeeinstellungen aktiviert ist."; "Scroll to see more models" = "Scrollen, um weitere Modelle zu sehen"; +"adaptive_activity_consent_title" = "Aktivitätsabhängige Aktualisierung erlauben?"; +"adaptive_activity_consent_message" = "Die adaptive Aktualisierung kann die Liste der lokal laufenden Prozesse einschließlich ihrer Befehlszeilen prüfen, um Codex und Claude zu erkennen, und anschließend beim Programmieren alle 30 Sekunden bekannte Sitzungsmetadaten lesen. Wenn Agent Sessions deaktiviert ist, verwendet CodexBar nur den Zeitpunkt der letzten Aktivität im Arbeitsspeicher und verwirft Sitzungspfade und Identitäten. Diese Aktivitätsdaten werden nirgendwohin gesendet; Remote-Erkennung und SSH bleiben deaktiviert. Bei Ablehnung verwendet der Modus Adaptiv weiterhin die Menüaktivität und überspringt lokale Aktivitätsscans. Sie können dies später unter Einstellungen > Allgemein ändern."; +"adaptive_activity_consent_allow" = "Lokale Aktivität erlauben"; +"adaptive_activity_consent_decline" = "Nur Menüaktivität verwenden"; +"adaptive_activity_scan_title" = "Lokale Programmieraktivität verwenden"; +"adaptive_activity_scan_subtitle" = "Prüft alle 30 Sekunden die Liste laufender Prozesse und bekannte Codex-/Claude-Sitzungsmetadaten. Es wird nur der Zeitpunkt der letzten Aktivität verwendet; diese Aktivitätsdaten werden nicht gesendet, und Remote-Erkennung sowie SSH bleiben deaktiviert."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 3ac738963c..408b8e128d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1187,3 +1187,9 @@ "Show Codex Spark usage" = "Show Codex Spark usage"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings."; "Scroll to see more models" = "Scroll to see more models"; +"adaptive_activity_consent_title" = "Allow activity-aware refresh?"; +"adaptive_activity_consent_message" = "Adaptive refresh can inspect the local running-process list, including command lines, to identify Codex and Claude, then read known session metadata every 30 seconds while you are coding. With Agent Sessions off, CodexBar uses only the latest activity time in memory and discards session paths and identities. This activity data is not sent anywhere, and remote discovery and SSH stay off. If you decline, Adaptive still uses menu activity and skips local activity scans. You can change this later in Settings > General."; +"adaptive_activity_consent_allow" = "Allow Local Activity"; +"adaptive_activity_consent_decline" = "Use Menu Activity Only"; +"adaptive_activity_scan_title" = "Use local coding activity"; +"adaptive_activity_scan_subtitle" = "Checks the running-process list and known Codex/Claude session metadata every 30 seconds. Only the latest activity time is used; this activity data is not sent, and remote discovery and SSH stay off."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 425dbb8f7d..4d1a2b2bac 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1041,3 +1041,9 @@ "Show Codex Spark usage" = "Mostrar el uso de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Muestra las filas de cuota de Codex Spark en el menú y en la vista previa del proveedor. Requiere activar «Mostrar créditos + uso adicional» en los ajustes de Pantalla."; "Scroll to see more models" = "Desplázate para ver más modelos"; +"adaptive_activity_consent_title" = "¿Permitir la actualización según la actividad?"; +"adaptive_activity_consent_message" = "La actualización adaptativa puede inspeccionar la lista de procesos locales en ejecución, incluidas las líneas de comandos, para identificar Codex y Claude, y después leer los metadatos de sesiones conocidas cada 30 segundos mientras programas. Con Agent Sessions desactivado, CodexBar solo conserva en memoria la hora de la actividad más reciente y descarta las rutas e identidades de las sesiones. Estos datos de actividad no se envían a ningún sitio, y la detección remota y SSH permanecen desactivados. Si rechazas, el modo Adaptativo seguirá usando la actividad del menú y omitirá los análisis de actividad local. Puedes cambiarlo más tarde en Ajustes > General."; +"adaptive_activity_consent_allow" = "Permitir actividad local"; +"adaptive_activity_consent_decline" = "Usar solo la actividad del menú"; +"adaptive_activity_scan_title" = "Usar actividad de programación local"; +"adaptive_activity_scan_subtitle" = "Inspecciona la lista de procesos en ejecución y los metadatos de sesiones conocidas de Codex/Claude cada 30 segundos. Solo se usa la hora de la actividad más reciente; estos datos de actividad no se envían, y la detección remota y SSH permanecen desactivados."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 2491f6d09a..4a47572449 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1187,3 +1187,9 @@ "Show Codex Spark usage" = "نمایش استفاده از Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "ردیف‌های سهمیه Codex Spark را در منو و پیش‌نمایش ارائه‌دهنده نمایش می‌دهد. لازم است «نمایش اعتبارها + استفاده اضافی» در تنظیمات نمایش فعال باشد."; "Scroll to see more models" = "برای دیدن مدل‌های بیشتر پیمایش کنید"; +"adaptive_activity_consent_title" = "اجازه به تازه‌سازی آگاه از فعالیت؟"; +"adaptive_activity_consent_message" = "تازه‌سازی تطبیقی می‌تواند برای شناسایی Codex و Claude، فهرست فرایندهای محلی در حال اجرا، از جمله خط‌های فرمان، را بررسی کند و سپس هنگام کدنویسی هر ۳۰ ثانیه فرادادهٔ نشست‌های شناخته‌شده را بخواند. وقتی Agent Sessions خاموش است، CodexBar فقط زمان آخرین فعالیت را در حافظه نگه می‌دارد و مسیرها و هویت‌های نشست را دور می‌ریزد. این دادهٔ فعالیت به هیچ‌جا ارسال نمی‌شود و شناسایی راه‌دور و SSH خاموش می‌مانند. اگر رد کنید، حالت تطبیقی همچنان از فعالیت منو استفاده می‌کند و پویش‌های فعالیت محلی را انجام نمی‌دهد. بعداً می‌توانید این مورد را در تنظیمات > عمومی تغییر دهید."; +"adaptive_activity_consent_allow" = "اجازه به فعالیت محلی"; +"adaptive_activity_consent_decline" = "فقط استفاده از فعالیت منو"; +"adaptive_activity_scan_title" = "استفاده از فعالیت کدنویسی محلی"; +"adaptive_activity_scan_subtitle" = "فهرست فرایندهای در حال اجرا و فرادادهٔ نشست‌های شناخته‌شدهٔ Codex/Claude را هر ۳۰ ثانیه بررسی می‌کند. فقط زمان آخرین فعالیت استفاده می‌شود؛ این دادهٔ فعالیت ارسال نمی‌شود و شناسایی راه‌دور و SSH خاموش می‌مانند."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 7d35cc746c..7302e3d9ed 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1180,3 +1180,9 @@ "Show Codex Spark usage" = "Afficher l’utilisation de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Affiche les lignes de quota Codex Spark dans le menu et l’aperçu du fournisseur. Nécessite d’activer « Afficher les crédits + utilisation supplémentaire » dans les réglages Affichage."; "Scroll to see more models" = "Faites défiler pour voir plus de modèles"; +"adaptive_activity_consent_title" = "Autoriser l’actualisation selon l’activité ?"; +"adaptive_activity_consent_message" = "L’actualisation adaptative peut examiner la liste des processus locaux en cours, y compris leurs lignes de commande, pour identifier Codex et Claude, puis lire toutes les 30 secondes les métadonnées des sessions connues pendant que vous codez. Lorsque Agent Sessions est désactivé, CodexBar ne conserve en mémoire que l’heure de la dernière activité et ignore les chemins et identités des sessions. Ces données d’activité ne sont envoyées nulle part, et la détection à distance ainsi que SSH restent désactivés. Si vous refusez, le mode Adaptatif utilise toujours l’activité du menu et ignore les analyses d’activité locale. Vous pourrez modifier ce choix plus tard dans Réglages > Général."; +"adaptive_activity_consent_allow" = "Autoriser l’activité locale"; +"adaptive_activity_consent_decline" = "Utiliser uniquement l’activité du menu"; +"adaptive_activity_scan_title" = "Utiliser l’activité de codage locale"; +"adaptive_activity_scan_subtitle" = "Examine toutes les 30 secondes la liste des processus en cours et les métadonnées des sessions Codex/Claude connues. Seule l’heure de la dernière activité est utilisée ; ces données d’activité ne sont pas envoyées, et la détection à distance ainsi que SSH restent désactivés."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 28999004ba..a779dec2d4 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1182,3 +1182,9 @@ "Show Codex Spark usage" = "Amosar o uso de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Amosa as filas de cota de Codex Spark no menú e na previsualización do provedor. Require activar «Amosar créditos + uso adicional» nos axustes de Pantalla."; "Scroll to see more models" = "Desprázate para ver máis modelos"; +"adaptive_activity_consent_title" = "Permitir a actualización segundo a actividade?"; +"adaptive_activity_consent_message" = "A actualización adaptativa pode inspeccionar a lista de procesos locais en execución, incluídas as liñas de comandos, para identificar Codex e Claude, e despois ler os metadatos de sesións coñecidas cada 30 segundos mentres programas. Con Agent Sessions desactivado, CodexBar só conserva na memoria a hora da actividade máis recente e descarta as rutas e identidades das sesións. Estes datos de actividade non se envían a ningún sitio, e a detección remota e SSH seguen desactivados. Se rexeitas, o modo Adaptativo segue usando a actividade do menú e omite as exploracións de actividade local. Podes cambialo máis adiante en Axustes > Xeral."; +"adaptive_activity_consent_allow" = "Permitir actividade local"; +"adaptive_activity_consent_decline" = "Usar só a actividade do menú"; +"adaptive_activity_scan_title" = "Usar a actividade de programación local"; +"adaptive_activity_scan_subtitle" = "Inspecciona cada 30 segundos a lista de procesos en execución e os metadatos de sesións coñecidas de Codex/Claude. Só se usa a hora da actividade máis recente; estes datos de actividade non se envían, e a detección remota e SSH seguen desactivados."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2cbf1a0105..0976a1d1c4 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1186,3 +1186,9 @@ "Show Codex Spark usage" = "Tampilkan penggunaan Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Menampilkan baris kuota Codex Spark di menu dan pratinjau penyedia. Mengharuskan “Tampilkan kredit + penggunaan ekstra” diaktifkan di pengaturan Tampilan."; "Scroll to see more models" = "Gulir untuk melihat model lainnya"; +"adaptive_activity_consent_title" = "Izinkan penyegaran yang peka terhadap aktivitas?"; +"adaptive_activity_consent_message" = "Penyegaran adaptif dapat memeriksa daftar proses lokal yang sedang berjalan, termasuk baris perintah, untuk mengenali Codex dan Claude, lalu membaca metadata sesi yang dikenal setiap 30 detik saat Anda menulis kode. Saat Agent Sessions dinonaktifkan, CodexBar hanya menggunakan waktu aktivitas terbaru di memori serta membuang jalur dan identitas sesi. Data aktivitas ini tidak dikirim ke mana pun, dan penemuan jarak jauh serta SSH tetap nonaktif. Jika Anda menolak, mode Adaptif tetap menggunakan aktivitas menu dan melewati pemindaian aktivitas lokal. Anda dapat mengubahnya nanti di Pengaturan > Umum."; +"adaptive_activity_consent_allow" = "Izinkan Aktivitas Lokal"; +"adaptive_activity_consent_decline" = "Gunakan Aktivitas Menu Saja"; +"adaptive_activity_scan_title" = "Gunakan aktivitas penulisan kode lokal"; +"adaptive_activity_scan_subtitle" = "Memeriksa daftar proses yang berjalan dan metadata sesi Codex/Claude yang dikenal setiap 30 detik. Hanya waktu aktivitas terbaru yang digunakan; data aktivitas ini tidak dikirim, dan penemuan jarak jauh serta SSH tetap nonaktif."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 26e5b34cb4..a28230bb03 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1186,3 +1186,9 @@ "Show Codex Spark usage" = "Mostra l’utilizzo di Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra le righe della quota Codex Spark nel menu e nell’anteprima del provider. Richiede di attivare «Mostra crediti + uso extra» nelle impostazioni Aspetto."; "Scroll to see more models" = "Scorri per vedere altri modelli"; +"adaptive_activity_consent_title" = "Consentire l’aggiornamento basato sull’attività?"; +"adaptive_activity_consent_message" = "L’aggiornamento adattivo può esaminare l’elenco dei processi locali in esecuzione, incluse le righe di comando, per identificare Codex e Claude, quindi leggere ogni 30 secondi i metadati delle sessioni note mentre programmi. Quando Agent Sessions è disattivato, CodexBar conserva in memoria solo l’ora dell’attività più recente e scarta percorsi e identità delle sessioni. Questi dati sull’attività non vengono inviati da nessuna parte; il rilevamento remoto e SSH restano disattivati. Se rifiuti, la modalità Adattiva continua a usare l’attività del menu e non esegue scansioni dell’attività locale. Puoi modificare questa scelta in seguito in Impostazioni > Generali."; +"adaptive_activity_consent_allow" = "Consenti attività locale"; +"adaptive_activity_consent_decline" = "Usa solo l’attività del menu"; +"adaptive_activity_scan_title" = "Usa l’attività di programmazione locale"; +"adaptive_activity_scan_subtitle" = "Esamina ogni 30 secondi l’elenco dei processi in esecuzione e i metadati delle sessioni Codex/Claude note. Viene usata solo l’ora dell’attività più recente; questi dati sull’attività non vengono inviati, e il rilevamento remoto e SSH restano disattivati."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 6b9fcbfc99..e383c70e38 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1181,3 +1181,9 @@ "Show Codex Spark usage" = "Codex Spark の使用量を表示"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "メニューとプロバイダのプレビューに Codex Spark のクォータ行を表示します。表示設定で「クレジットと追加使用量を表示」を有効にする必要があります。"; "Scroll to see more models" = "スクロールして他のモデルを表示"; +"adaptive_activity_consent_title" = "アクティビティ対応の更新を許可しますか?"; +"adaptive_activity_consent_message" = "アダプティブ更新では、Codex と Claude を識別するために、コマンドラインを含むローカルの実行中プロセス一覧を調べ、コーディング中は既知のセッションメタデータを 30 秒ごとに読み取ることができます。Agent Sessions がオフの場合、CodexBar は最新のアクティビティ時刻だけをメモリで使用し、セッションのパスと識別情報を破棄します。このアクティビティデータが外部に送信されることはなく、リモート検出と SSH はオフのままです。許可しない場合も、「アダプティブ」はメニューのアクティビティを使用し、ローカルアクティビティのスキャンは行いません。この設定は後で「設定」>「一般」で変更できます。"; +"adaptive_activity_consent_allow" = "ローカルアクティビティを許可"; +"adaptive_activity_consent_decline" = "メニューのアクティビティのみを使用"; +"adaptive_activity_scan_title" = "ローカルのコーディングアクティビティを使用"; +"adaptive_activity_scan_subtitle" = "実行中プロセスの一覧と、既知の Codex/Claude セッションメタデータを 30 秒ごとに確認します。使用するのは最新のアクティビティ時刻のみです。このアクティビティデータは送信されず、リモート検出と SSH はオフのままです。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index b30f6bc86a..8eda989292 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1149,3 +1149,9 @@ "Show Codex Spark usage" = "Codex Spark 사용량 표시"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "메뉴와 공급자 미리보기에 Codex Spark 할당량 행을 표시합니다. 표시 설정에서 ‘크레딧 + 추가 사용량 표시’를 활성화해야 합니다."; "Scroll to see more models" = "스크롤하여 더 많은 모델 보기"; +"adaptive_activity_consent_title" = "활동 인식 새로 고침을 허용할까요?"; +"adaptive_activity_consent_message" = "적응형 새로 고침은 Codex와 Claude를 식별하기 위해 명령줄을 포함한 로컬 실행 중 프로세스 목록을 검사한 다음, 코딩하는 동안 30초마다 알려진 세션 메타데이터를 읽을 수 있습니다. Agent Sessions를 끄면 CodexBar는 메모리에서 가장 최근 활동 시간만 사용하고 세션 경로와 ID는 폐기합니다. 이 활동 데이터는 어디에도 전송되지 않으며 원격 검색 및 SSH는 꺼진 상태로 유지됩니다. 거부해도 적응형 모드는 메뉴 활동을 계속 사용하고 로컬 활동 검사는 건너뜁니다. 나중에 설정 > 일반에서 변경할 수 있습니다."; +"adaptive_activity_consent_allow" = "로컬 활동 허용"; +"adaptive_activity_consent_decline" = "메뉴 활동만 사용"; +"adaptive_activity_scan_title" = "로컬 코딩 활동 사용"; +"adaptive_activity_scan_subtitle" = "30초마다 실행 중 프로세스 목록과 알려진 Codex/Claude 세션 메타데이터를 검사합니다. 가장 최근 활동 시간만 사용하며, 이 활동 데이터는 전송되지 않고 원격 검색 및 SSH는 꺼진 상태로 유지됩니다."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index ceeea8bdb6..25b6b26bcd 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1180,3 +1180,9 @@ "Show Codex Spark usage" = "Codex Spark-gebruik tonen"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Toont Codex Spark-quotumregels in het menu en de voorvertoning van de provider. Vereist dat ‘Toon credits + extra gebruik’ is ingeschakeld in de instellingen voor Weergave."; "Scroll to see more models" = "Scroll om meer modellen te bekijken"; +"adaptive_activity_consent_title" = "Activiteitsgestuurd vernieuwen toestaan?"; +"adaptive_activity_consent_message" = "Adaptief vernieuwen kan de lijst met lokaal actieve processen, inclusief opdrachtregels, controleren om Codex en Claude te herkennen en vervolgens tijdens het programmeren elke 30 seconden bekende sessiemetadata lezen. Als Agent Sessions uitstaat, gebruikt CodexBar in het geheugen alleen het tijdstip van de meest recente activiteit en verwijdert het sessiepaden en identiteiten. Deze activiteitsgegevens worden nergens naartoe gestuurd en detectie op afstand en SSH blijven uitgeschakeld. Als je weigert, gebruikt Adaptief nog steeds menuactiviteit en worden lokale activiteitsscans overgeslagen. Je kunt dit later wijzigen via Instellingen > Algemeen."; +"adaptive_activity_consent_allow" = "Lokale activiteit toestaan"; +"adaptive_activity_consent_decline" = "Alleen menuactiviteit gebruiken"; +"adaptive_activity_scan_title" = "Lokale programmeeractiviteit gebruiken"; +"adaptive_activity_scan_subtitle" = "Controleert elke 30 seconden de lijst met actieve processen en bekende Codex/Claude-sessiemetadata. Alleen het tijdstip van de meest recente activiteit wordt gebruikt; deze activiteitsgegevens worden niet verstuurd en detectie op afstand en SSH blijven uitgeschakeld."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index ba2fef33ba..4a72feaf19 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1186,3 +1186,9 @@ "Show Codex Spark usage" = "Pokaż użycie Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Pokazuje wiersze limitu Codex Spark w menu i podglądzie dostawcy. Wymaga włączenia opcji „Pokaż kredyty + dodatkowe użycie” w ustawieniach Wyświetlanie."; "Scroll to see more models" = "Przewiń, aby zobaczyć więcej modeli"; +"adaptive_activity_consent_title" = "Zezwolić na odświeżanie uwzględniające aktywność?"; +"adaptive_activity_consent_message" = "Odświeżanie adaptacyjne może sprawdzać listę uruchomionych procesów lokalnych, w tym wiersze poleceń, aby rozpoznać Codex i Claude, a następnie podczas programowania co 30 sekund odczytywać metadane znanych sesji. Gdy Agent Sessions jest wyłączone, CodexBar używa w pamięci tylko czasu ostatniej aktywności i odrzuca ścieżki oraz tożsamości sesji. Te dane o aktywności nie są nigdzie wysyłane, a wykrywanie zdalne i SSH pozostają wyłączone. Jeśli odmówisz, tryb Adaptacyjny nadal używa aktywności menu i pomija skanowanie aktywności lokalnej. Możesz to później zmienić w Ustawienia > Ogólne."; +"adaptive_activity_consent_allow" = "Zezwól na lokalną aktywność"; +"adaptive_activity_consent_decline" = "Używaj tylko aktywności menu"; +"adaptive_activity_scan_title" = "Używaj lokalnej aktywności programowania"; +"adaptive_activity_scan_subtitle" = "Co 30 sekund sprawdza listę uruchomionych procesów i metadane znanych sesji Codex/Claude. Używany jest tylko czas ostatniej aktywności; te dane o aktywności nie są wysyłane, a wykrywanie zdalne i SSH pozostają wyłączone."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 6f111fca66..9cf8e2d711 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1181,3 +1181,9 @@ "Show Codex Spark usage" = "Mostrar uso do Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra as linhas de cota do Codex Spark no menu e na prévia do provedor. Requer ativar “Mostrar créditos + uso extra” nos ajustes de Exibição."; "Scroll to see more models" = "Role para ver mais modelos"; +"adaptive_activity_consent_title" = "Permitir atualização conforme a atividade?"; +"adaptive_activity_consent_message" = "A atualização adaptativa pode inspecionar a lista de processos locais em execução, incluindo as linhas de comando, para identificar Codex e Claude e, enquanto você programa, ler os metadados de sessões conhecidas a cada 30 segundos. Com Agent Sessions desativado, o CodexBar mantém na memória apenas o horário da atividade mais recente e descarta os caminhos e as identidades das sessões. Esses dados de atividade não são enviados a lugar algum, e a detecção remota e o SSH permanecem desativados. Se você recusar, o modo Adaptativo continuará usando a atividade do menu e não fará verificações de atividade local. Você pode alterar isso depois em Ajustes > Geral."; +"adaptive_activity_consent_allow" = "Permitir atividade local"; +"adaptive_activity_consent_decline" = "Usar somente atividade do menu"; +"adaptive_activity_scan_title" = "Usar atividade local de programação"; +"adaptive_activity_scan_subtitle" = "Inspeciona a lista de processos em execução e os metadados de sessões conhecidas do Codex/Claude a cada 30 segundos. Apenas o horário da atividade mais recente é usado; esses dados de atividade não são enviados, e a detecção remota e o SSH permanecem desativados."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 378bb4a99b..56b65d8106 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1187,3 +1187,9 @@ "Show Codex Spark usage" = "Показывать использование Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показывает строки квот Codex Spark в меню и предварительном просмотре провайдера. Требует включить «Показывать кредиты и доп. использование» в настройках «Отображение»."; "Scroll to see more models" = "Прокрутите, чтобы увидеть больше моделей"; +"adaptive_activity_consent_title" = "Разрешить обновление с учётом активности?"; +"adaptive_activity_consent_message" = "Адаптивное обновление может проверять список запущенных локальных процессов, включая командные строки, чтобы распознавать Codex и Claude, а затем во время программирования каждые 30 секунд считывать метаданные известных сеансов. Когда Agent Sessions отключено, CodexBar хранит в памяти только время последней активности и отбрасывает пути и идентификаторы сеансов. Эти данные об активности никуда не отправляются, а удалённое обнаружение и SSH остаются отключёнными. При отказе адаптивный режим продолжит использовать активность меню и не будет сканировать локальную активность. Это можно изменить позже в Настройки > Общие."; +"adaptive_activity_consent_allow" = "Разрешить локальную активность"; +"adaptive_activity_consent_decline" = "Использовать только активность меню"; +"adaptive_activity_scan_title" = "Использовать локальную активность программирования"; +"adaptive_activity_scan_subtitle" = "Каждые 30 секунд проверяет список запущенных процессов и метаданные известных сеансов Codex/Claude. Используется только время последней активности; эти данные об активности не отправляются, а удалённое обнаружение и SSH остаются отключёнными."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a66aef8bbb..109945a626 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1179,3 +1179,9 @@ "Show Codex Spark usage" = "Visa Codex Spark-användning"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Visar kvotrader för Codex Spark i menyn och i förhandsvisningen för leverantören. Kräver att ”Visa krediter och extra användning” är aktiverat under Visning i Inställningar."; "Scroll to see more models" = "Rulla för att se fler modeller"; +"adaptive_activity_consent_title" = "Tillåta aktivitetsmedveten uppdatering?"; +"adaptive_activity_consent_message" = "Adaptiv uppdatering kan granska listan över lokala processer som körs, inklusive kommandorader, för att identifiera Codex och Claude och sedan läsa kända sessionsmetadata var 30:e sekund medan du kodar. När Agent Sessions är avstängt använder CodexBar endast tiden för den senaste aktiviteten i minnet och kasserar sessionssökvägar och identiteter. Dessa aktivitetsdata skickas ingenstans, och fjärridentifiering och SSH förblir avstängda. Om du avböjer fortsätter Adaptiv att använda menyaktivitet och hoppar över lokala aktivitetsskanningar. Du kan ändra detta senare under Inställningar > Allmänt."; +"adaptive_activity_consent_allow" = "Tillåt lokal aktivitet"; +"adaptive_activity_consent_decline" = "Använd endast menyaktivitet"; +"adaptive_activity_scan_title" = "Använd lokal kodningsaktivitet"; +"adaptive_activity_scan_subtitle" = "Granskar listan över processer som körs och kända Codex/Claude-sessionsmetadata var 30:e sekund. Endast tiden för den senaste aktiviteten används; dessa aktivitetsdata skickas inte, och fjärridentifiering och SSH förblir avstängda."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 690767b55c..ce91e24fd9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1187,3 +1187,9 @@ "Show Codex Spark usage" = "แสดงการใช้งาน Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "แสดงแถวโควตา Codex Spark ในเมนูและตัวอย่างผู้ให้บริการ ต้องเปิดใช้ “แสดงเครดิต + การใช้งานเพิ่มเติม” ในการตั้งค่าการแสดงผล"; "Scroll to see more models" = "เลื่อนเพื่อดูโมเดลเพิ่มเติม"; +"adaptive_activity_consent_title" = "อนุญาตการรีเฟรชตามกิจกรรมหรือไม่"; +"adaptive_activity_consent_message" = "การรีเฟรชแบบปรับได้สามารถตรวจสอบรายการโปรเซสที่กำลังทำงานในเครื่อง รวมถึงบรรทัดคำสั่ง เพื่อระบุ Codex และ Claude จากนั้นอ่านเมตาดาตาของเซสชันที่รู้จักทุก 30 วินาทีขณะคุณเขียนโค้ด เมื่อปิด Agent Sessions CodexBar จะใช้เฉพาะเวลาของกิจกรรมล่าสุดในหน่วยความจำ และทิ้งพาธกับข้อมูลระบุตัวตนของเซสชัน ข้อมูลกิจกรรมนี้จะไม่ถูกส่งไปที่ใด และการค้นหาระยะไกลกับ SSH จะยังคงปิดอยู่ หากคุณปฏิเสธ โหมดปรับได้จะยังคงใช้กิจกรรมจากเมนูและข้ามการสแกนกิจกรรมในเครื่อง คุณเปลี่ยนได้ภายหลังใน การตั้งค่า > ทั่วไป"; +"adaptive_activity_consent_allow" = "อนุญาตกิจกรรมในเครื่อง"; +"adaptive_activity_consent_decline" = "ใช้เฉพาะกิจกรรมจากเมนู"; +"adaptive_activity_scan_title" = "ใช้กิจกรรมการเขียนโค้ดในเครื่อง"; +"adaptive_activity_scan_subtitle" = "ตรวจสอบรายการโปรเซสที่กำลังทำงานและเมตาดาตาของเซสชัน Codex/Claude ที่รู้จักทุก 30 วินาที ใช้เฉพาะเวลาของกิจกรรมล่าสุด ข้อมูลกิจกรรมนี้จะไม่ถูกส่ง และการค้นหาระยะไกลกับ SSH จะยังคงปิดอยู่"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ad306f55dd..c6b3b098d1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1184,3 +1184,9 @@ "Show Codex Spark usage" = "Codex Spark kullanımını göster"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Codex Spark kota satırlarını menüde ve sağlayıcı önizlemesinde gösterir. Görünüm ayarlarında “Krediler + ekstra kullanımı göster” seçeneğinin etkin olmasını gerektirir."; "Scroll to see more models" = "Daha fazla model görmek için kaydırın"; +"adaptive_activity_consent_title" = "Etkinliğe duyarlı yenilemeye izin verilsin mi?"; +"adaptive_activity_consent_message" = "Uyarlanabilir yenileme, Codex ve Claude'u tanımak için komut satırları dahil yerel çalışan işlemler listesini inceleyebilir, ardından siz kod yazarken bilinen oturum meta verilerini 30 saniyede bir okuyabilir. Agent Sessions kapalıyken CodexBar bellekte yalnızca en son etkinlik zamanını kullanır ve oturum yolları ile kimliklerini atar. Bu etkinlik verileri hiçbir yere gönderilmez; uzaktan keşif ve SSH kapalı kalır. Reddederseniz Uyarlanabilir, menü etkinliğini kullanmaya devam eder ve yerel etkinlik taramalarını atlar. Bunu daha sonra Ayarlar > Genel bölümünden değiştirebilirsiniz."; +"adaptive_activity_consent_allow" = "Yerel Etkinliğe İzin Ver"; +"adaptive_activity_consent_decline" = "Yalnızca Menü Etkinliğini Kullan"; +"adaptive_activity_scan_title" = "Yerel kodlama etkinliğini kullan"; +"adaptive_activity_scan_subtitle" = "Çalışan işlemler listesini ve bilinen Codex/Claude oturum meta verilerini 30 saniyede bir inceler. Yalnızca en son etkinlik zamanı kullanılır; bu etkinlik verileri gönderilmez, uzaktan keşif ve SSH kapalı kalır."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 3a8546f6b9..2637f624e8 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1180,3 +1180,9 @@ "Show Codex Spark usage" = "Показати використання Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показує рядки квоти Codex Spark у меню та попередньому перегляді провайдера. Потрібно ввімкнути «Показати кредити + додаткове використання» в налаштуваннях «Відображення»."; "Scroll to see more models" = "Прокрутіть, щоб побачити більше моделей"; +"adaptive_activity_consent_title" = "Дозволити оновлення з урахуванням активності?"; +"adaptive_activity_consent_message" = "Адаптивне оновлення може перевіряти список запущених локальних процесів, зокрема командні рядки, щоб розпізнавати Codex і Claude, а потім під час програмування кожні 30 секунд зчитувати метадані відомих сеансів. Коли Agent Sessions вимкнено, CodexBar зберігає в пам’яті лише час останньої активності та відкидає шляхи й ідентифікатори сеансів. Ці дані про активність нікуди не надсилаються, а віддалене виявлення та SSH залишаються вимкненими. Якщо відмовитися, адаптивний режим і далі використовуватиме активність меню та не скануватиме локальну активність. Це можна змінити пізніше в Налаштування > Загальні."; +"adaptive_activity_consent_allow" = "Дозволити локальну активність"; +"adaptive_activity_consent_decline" = "Використовувати лише активність меню"; +"adaptive_activity_scan_title" = "Використовувати локальну активність програмування"; +"adaptive_activity_scan_subtitle" = "Кожні 30 секунд перевіряє список запущених процесів і метадані відомих сеансів Codex/Claude. Використовується лише час останньої активності; ці дані про активність не надсилаються, а віддалене виявлення та SSH залишаються вимкненими."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 784461509f..8a64a8601a 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1181,3 +1181,9 @@ "Show Codex Spark usage" = "Hiển thị mức sử dụng Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Hiển thị các hàng hạn mức Codex Spark trong menu và bản xem trước của nhà cung cấp. Yêu cầu bật “Hiển thị tín dụng + mức sử dụng bổ sung” trong phần cài đặt Hiển thị."; "Scroll to see more models" = "Cuộn để xem thêm mô hình"; +"adaptive_activity_consent_title" = "Cho phép làm mới theo hoạt động?"; +"adaptive_activity_consent_message" = "Tính năng làm mới thích ứng có thể kiểm tra danh sách tiến trình cục bộ đang chạy, bao gồm cả dòng lệnh, để nhận diện Codex và Claude, sau đó đọc siêu dữ liệu của các phiên đã biết mỗi 30 giây trong khi bạn viết mã. Khi tắt Agent Sessions, CodexBar chỉ dùng thời điểm hoạt động gần nhất trong bộ nhớ và loại bỏ đường dẫn cùng danh tính phiên. Dữ liệu hoạt động này không được gửi đi đâu, còn tính năng phát hiện từ xa và SSH vẫn tắt. Nếu bạn từ chối, chế độ Thích ứng vẫn dùng hoạt động trong menu và bỏ qua việc quét hoạt động cục bộ. Bạn có thể thay đổi sau trong Cài đặt > Chung."; +"adaptive_activity_consent_allow" = "Cho phép hoạt động cục bộ"; +"adaptive_activity_consent_decline" = "Chỉ dùng hoạt động trong menu"; +"adaptive_activity_scan_title" = "Dùng hoạt động viết mã cục bộ"; +"adaptive_activity_scan_subtitle" = "Kiểm tra danh sách tiến trình đang chạy và siêu dữ liệu của các phiên Codex/Claude đã biết mỗi 30 giây. Chỉ thời điểm hoạt động gần nhất được sử dụng; dữ liệu hoạt động này không được gửi đi, còn tính năng phát hiện từ xa và SSH vẫn tắt."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 4687a31219..be6d09e97e 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1157,3 +1157,9 @@ "Show Codex Spark usage" = "显示 Codex Spark 用量"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在菜单和提供商预览中显示 Codex Spark 配额行。需要在“显示”设置中启用“显示额度 + 额外用量”。"; "Scroll to see more models" = "滚动查看更多模型"; +"adaptive_activity_consent_title" = "允许根据活动自适应刷新?"; +"adaptive_activity_consent_message" = "自适应刷新可以检查本地运行中的进程列表(包括命令行)来识别 Codex 和 Claude,并在你编写代码时每 30 秒读取一次已知的会话元数据。关闭 Agent Sessions 时,CodexBar 仅在内存中使用最近一次活动时间,并丢弃会话路径和身份信息。此活动数据不会发送到任何地方,远程探测和 SSH 保持关闭。即使拒绝,自适应模式仍会使用菜单活动,并跳过本地活动扫描。你可以稍后在“设置”>“通用”中更改此选项。"; +"adaptive_activity_consent_allow" = "允许本地活动"; +"adaptive_activity_consent_decline" = "仅使用菜单活动"; +"adaptive_activity_scan_title" = "使用本地编码活动"; +"adaptive_activity_scan_subtitle" = "每 30 秒检查一次运行中的进程列表和已知的 Codex/Claude 会话元数据。仅使用最近一次活动时间;此活动数据不会被发送,远程探测和 SSH 保持关闭。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 5454de5d24..99ab0f58c5 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1216,3 +1216,9 @@ "Show Codex Spark usage" = "顯示 Codex Spark 使用量"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在選單和提供者預覽中顯示 Codex Spark 配額列。需要在「顯示」設定中啟用「顯示額度 + 額外使用量」。"; "Scroll to see more models" = "捲動查看更多模型"; +"adaptive_activity_consent_title" = "允許根據活動自適應重新整理?"; +"adaptive_activity_consent_message" = "自適應重新整理可以檢查本機執行中的程序列表(包括命令列)來辨識 Codex 和 Claude,並在你編寫程式碼時每 30 秒讀取一次已知的工作階段中繼資料。關閉 Agent Sessions 時,CodexBar 僅在記憶體中使用最近一次活動時間,並捨棄工作階段路徑和身分資訊。此活動資料不會傳送到任何地方,遠端偵測和 SSH 保持關閉。即使拒絕,自適應模式仍會使用選單活動,並略過本機活動掃描。你可以稍後在「設定」>「一般」中變更此選項。"; +"adaptive_activity_consent_allow" = "允許本機活動"; +"adaptive_activity_consent_decline" = "僅使用選單活動"; +"adaptive_activity_scan_title" = "使用本機編碼活動"; +"adaptive_activity_scan_subtitle" = "每 30 秒檢查一次執行中的程序列表和已知的 Codex/Claude 工作階段中繼資料。僅使用最近一次活動時間;此活動資料不會被傳送,遠端偵測和 SSH 保持關閉。"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 6ed94842de..92e79212cc 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -18,6 +18,25 @@ extension SettingsStore { } } + var adaptiveActivityScanConsent: AdaptiveActivityScanConsent { + get { self.defaultsState.adaptiveActivityScanConsent } + set { + self.defaultsState.adaptiveActivityScanConsent = newValue + self.userDefaults.set(newValue.rawValue, forKey: "adaptiveActivityScanConsent") + self.noteBackgroundWorkSettingsChanged() + } + } + + var adaptiveActivityScanningEnabled: Bool { + self.adaptiveActivityScanConsent == .allowed + } + + var shouldRequestAdaptiveActivityScanConsent: Bool { + self.refreshFrequency == .adaptive && + !self.agentSessionsEnabled && + self.adaptiveActivityScanConsent == .undecided + } + /// When enabled, keeping the menu open through its short refresh delay fetches usage for every /// enabled provider. The periodic refresh clock remains unchanged. See `scheduleOpenMenuRefresh`. var refreshAllProvidersOnMenuOpen: Bool { @@ -845,7 +864,9 @@ extension SettingsStore { for provider in providers where !seen.contains(provider) { seen.insert(provider) normalized.append(provider) - if let maxCount, normalized.count >= maxCount { break } + if let maxCount, normalized.count >= maxCount { + break + } } return normalized } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6e782fe044..73610bacee 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -5,6 +5,7 @@ extension SettingsStore { _ = self.providerOrder _ = self.providerEnablement _ = self.refreshFrequency + _ = self.adaptiveActivityScanConsent _ = self.launchAtLogin _ = self.debugMenuEnabled _ = self.debugDisableKeychainAccess diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 2689d22576..e4dce8e1e6 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -45,6 +45,12 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { } } +enum AdaptiveActivityScanConsent: String, Sendable { + case undecided + case allowed + case declined +} + enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case automatic case primary @@ -270,6 +276,9 @@ final class SettingsStore { antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore(), performInitialProviderDetection: Bool = !SettingsStore.isRunningTests) { + // Capture this before app-group/config migrations can create prior-installation state. + let hadExistingConfig = (try? configStore.load()) != nil + let hadPreviousInstallationState = hadExistingConfig || Self.hadPreviousAppLaunch(userDefaults: userDefaults) let appGroupID = AppGroupSupport.currentGroupID() let appGroupMigration: AppGroupSupport.MigrationResult if Self.isRunningTests { @@ -297,7 +306,6 @@ final class SettingsStore { userDefaults.set(legacyOpenAIWebAccess, forKey: "openAIWebAccessEnabled") } let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil - let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, @@ -323,7 +331,9 @@ final class SettingsStore { self.antigravityOAuthCredentialsStore = antigravityOAuthCredentialsStore self.config = config self.configLoading = true - let defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + let defaultsState = Self.loadDefaultsState( + userDefaults: userDefaults, + hadPreviousInstallationState: hadPreviousInstallationState) self.defaultsState = defaultsState self.mergedMenuLastSelectedWasOverviewStorage = defaultsState.mergedMenuLastSelectedWasOverview self.selectedMenuProviderRawStorage = defaultsState.selectedMenuProviderRaw @@ -396,13 +406,14 @@ extension SettingsStore { } // swiftlint:disable:next function_body_length - private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { - let refreshDefault = userDefaults.string(forKey: "refreshFrequency") - .flatMap(RefreshFrequency.init(rawValue:)) - let refreshFrequency = refreshDefault ?? .fiveMinutes - if Self.isRunningTests, refreshDefault == nil { - userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency") - } + private static func loadDefaultsState( + userDefaults: UserDefaults, + hadPreviousInstallationState: Bool) -> SettingsDefaultsState + { + let refreshFrequency = Self.loadRefreshFrequency( + userDefaults: userDefaults, + hadPreviousInstallationState: hadPreviousInstallationState) + let adaptiveActivityScanConsent = Self.loadAdaptiveActivityScanConsent(userDefaults: userDefaults) let refreshAllProvidersOnMenuOpen = userDefaults.object( forKey: "refreshAllProvidersOnMenuOpen") as? Bool ?? false let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false @@ -497,6 +508,7 @@ extension SettingsStore { let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" return SettingsDefaultsState( refreshFrequency: refreshFrequency, + adaptiveActivityScanConsent: adaptiveActivityScanConsent, refreshAllProvidersOnMenuOpen: refreshAllProvidersOnMenuOpen, launchAtLogin: launchAtLogin, debugMenuEnabled: debugMenuEnabled, @@ -562,6 +574,42 @@ extension SettingsStore { agentSessionsManualHosts: agentSessionsManualHosts) } + private static func hadPreviousAppLaunch(userDefaults: UserDefaults) -> Bool { + userDefaults.object(forKey: "providerDetectionCompleted") != nil || + userDefaults.object(forKey: AppGroupSupport.migrationVersionKey) != nil + } + + private static func loadRefreshFrequency( + userDefaults: UserDefaults, + hadPreviousInstallationState: Bool) -> RefreshFrequency + { + let rawValue = userDefaults.object(forKey: "refreshFrequency") + if let stored = rawValue as? String, + let frequency = RefreshFrequency(rawValue: stored) + { + return frequency + } + + // An invalid value is existing state. Missing state is Adaptive only when no prior-installation + // state existed before migrations began; legacy unset users keep the old five-minute fallback. + let frequency: RefreshFrequency = rawValue == nil && !hadPreviousInstallationState ? .adaptive : .fiveMinutes + userDefaults.set(frequency.rawValue, forKey: "refreshFrequency") + return frequency + } + + private static func loadAdaptiveActivityScanConsent( + userDefaults: UserDefaults) -> AdaptiveActivityScanConsent + { + if let rawValue = userDefaults.string(forKey: "adaptiveActivityScanConsent"), + let consent = AdaptiveActivityScanConsent(rawValue: rawValue) + { + return consent + } + + userDefaults.set(AdaptiveActivityScanConsent.undecided.rawValue, forKey: "adaptiveActivityScanConsent") + return .undecided + } + private static func loadNotificationDefaults(userDefaults: UserDefaults) -> NotificationDefaults { NotificationDefaults( statusChecksEnabled: userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 82c24e08d2..a622b4fe94 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -2,6 +2,7 @@ import Foundation struct SettingsDefaultsState { var refreshFrequency: RefreshFrequency + var adaptiveActivityScanConsent: AdaptiveActivityScanConsent var refreshAllProvidersOnMenuOpen: Bool var launchAtLogin: Bool var debugMenuEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+AgentSessions.swift b/Sources/CodexBar/StatusItemController+AgentSessions.swift index c2e42a389c..847b53b6be 100644 --- a/Sources/CodexBar/StatusItemController+AgentSessions.swift +++ b/Sources/CodexBar/StatusItemController+AgentSessions.swift @@ -1,6 +1,36 @@ import AppKit extension StatusItemController { + func wireAgentSessionUpdates() { + self.agentSessions.onUpdate = { [weak self] in + guard let self else { return } + if let latestActivityAt = self.agentSessions.latestLocalActivityAt { + self.store.noteCodingActivityObserved(at: latestActivityAt) + } else { + self.store.clearCodingActivityObservation() + } + if self.settings.agentSessionsEnabled { + self.invalidateMenus(refreshOpenMenus: true) + } + } + } + + func synchronizeAgentSessionsForSettingsChange() { + let remoteConfigurationChanged = + self.settings.agentSessionsEnabled != self.lastAgentSessionsEnabled || + self.settings.agentSessionsManualHosts != self.lastAgentSessionsManualHosts + let monitoringChanged = + self.settings.refreshFrequency != self.lastAgentSessionsRefreshFrequency || + self.settings.adaptiveActivityScanningEnabled != self.lastAdaptiveActivityScanningEnabled + guard remoteConfigurationChanged || monitoringChanged else { return } + + self.lastAgentSessionsEnabled = self.settings.agentSessionsEnabled + self.lastAgentSessionsManualHosts = self.settings.agentSessionsManualHosts + self.lastAgentSessionsRefreshFrequency = self.settings.refreshFrequency + self.lastAdaptiveActivityScanningEnabled = self.settings.adaptiveActivityScanningEnabled + self.agentSessions.settingsDidChange(remoteConfigurationChanged: remoteConfigurationChanged) + } + @objc func focusAgentSession(_ sender: NSMenuItem) { guard let values = sender.representedObject as? [String], let sessionID = values.first diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index e73f316d8c..8ac227ab91 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -251,8 +251,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool var lastWidgetDisplaySettingsSignature = "" - private var lastAgentSessionsEnabled: Bool - private var lastAgentSessionsManualHosts: String + var lastAgentSessionsEnabled: Bool + var lastAgentSessionsManualHosts: String + var lastAgentSessionsRefreshFrequency: RefreshFrequency + var lastAdaptiveActivityScanningEnabled: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool @@ -400,6 +402,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastAgentSessionsEnabled = settings.agentSessionsEnabled self.lastAgentSessionsManualHosts = settings.agentSessionsManualHosts + self.lastAgentSessionsRefreshFrequency = settings.refreshFrequency + self.lastAdaptiveActivityScanningEnabled = settings.adaptiveActivityScanningEnabled self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.menuCardRenderingEnabledForController = menuCardRenderingEnabled self.menuRefreshEnabledForController = menuRefreshEnabled @@ -423,9 +427,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion self.lastWidgetDisplaySettingsSignature = self.widgetDisplaySettingsSignature() self.wireBindings() - self.agentSessions.onUpdate = { [weak self] in - self?.invalidateMenus(refreshOpenMenus: true) - } + self.wireAgentSessionUpdates() if !SettingsStore.isRunningTests { self.agentSessions.start() } @@ -667,13 +669,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif - let agentSessionsSettingsChanged = self.settings.agentSessionsEnabled != self.lastAgentSessionsEnabled || - self.settings.agentSessionsManualHosts != self.lastAgentSessionsManualHosts - if agentSessionsSettingsChanged { - self.lastAgentSessionsEnabled = self.settings.agentSessionsEnabled - self.lastAgentSessionsManualHosts = self.settings.agentSessionsManualHosts - self.agentSessions.settingsDidChange() - } + self.synchronizeAgentSessionsForSettingsChange() let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature diff --git a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift index 05ab36cd57..6806cf522f 100644 --- a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift +++ b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift @@ -18,6 +18,7 @@ extension UsageStore { nonisolated static func adaptiveRefreshDecision( now: Date, lastMenuOpenAt: Date?, + lastCodingActivityAt: Date? = nil, lowPowerModeEnabled: Bool, thermalState: ProcessInfo.ThermalState, policy: AdaptiveRefreshPolicy = AdaptiveRefreshPolicy()) -> AdaptiveRefreshPolicy.Decision @@ -25,6 +26,7 @@ extension UsageStore { policy.nextDelay(for: AdaptiveRefreshPolicy.Input( now: now, lastMenuOpenAt: lastMenuOpenAt, + lastCodingActivityAt: lastCodingActivityAt, lowPowerModeEnabled: lowPowerModeEnabled, thermalState: thermalState)) } @@ -34,6 +36,27 @@ extension UsageStore { return candidate < scheduledAt } + func noteCodingActivityObserved(at date: Date, now: Date = Date()) { + self.retainCodingActivityIfNewer(date) + self.advanceAdaptiveTimerIfEarlier(at: now) + } + + func advanceAdaptiveTimerIfEarlier(at date: Date) { + guard self.settings.refreshFrequency == .adaptive else { return } + let decision = Self.adaptiveRefreshDecision( + now: date, + lastMenuOpenAt: self.lastMenuOpenAt, + lastCodingActivityAt: self.lastCodingActivityAt, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState) + let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + guard Self.shouldAdvanceAdaptiveTimer( + scheduledAt: self.adaptiveRefreshScheduledAt, + candidate: candidate) + else { return } + self.restartAdaptiveTimerPreservingResetBoundary() + } + /// Advances a fixed timer from the last scheduled tick instead of the refresh completion time. /// Missed ticks are skipped so a refresh that runs longer than its interval does not create /// overlapping catch-up refreshes. @@ -94,6 +117,7 @@ extension UsageStore { let decision = Self.adaptiveRefreshDecision( now: now, lastMenuOpenAt: store.lastMenuOpenAt, + lastCodingActivityAt: store.lastCodingActivityAt, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState) store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) @@ -116,6 +140,7 @@ extension UsageStore { TimeInterval(Self.adaptiveRefreshDecision( now: Date(), lastMenuOpenAt: self.lastMenuOpenAt, + lastCodingActivityAt: self.lastCodingActivityAt, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) default: diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6241a098ee..be956de454 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -288,6 +288,9 @@ final class UsageStore { @ObservationIgnored private var timerTask: Task? /// In-memory only; resets on every launch. @ObservationIgnored private(set) var lastMenuOpenAt: Date? + /// Latest local Codex/Claude transcript activity observed by the existing session scanner. + /// In-memory only; paths and session identities never enter the refresh policy. + @ObservationIgnored private(set) var lastCodingActivityAt: Date? @ObservationIgnored var adaptiveRefreshScheduledAt: Date? @ObservationIgnored var tokenTimerTask: Task? @ObservationIgnored var tokenRefreshSequenceTask: Task? @@ -1647,20 +1650,22 @@ extension UsageStore { } extension UsageStore { + func retainCodingActivityIfNewer(_ date: Date) { + if self.lastCodingActivityAt.map({ date > $0 }) ?? true { + self.lastCodingActivityAt = date + } + } + + func clearCodingActivityObservation() { + self.lastCodingActivityAt = nil + } + + func restartAdaptiveTimerPreservingResetBoundary() { + self.startTimer(preservingResetBoundaryRefresh: true) + } + func noteMenuOpened(at date: Date = Date()) { self.lastMenuOpenAt = date - guard self.settings.refreshFrequency == .adaptive else { return } - - let decision = Self.adaptiveRefreshDecision( - now: date, - lastMenuOpenAt: date, - lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, - thermalState: ProcessInfo.processInfo.thermalState) - let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) - guard Self.shouldAdvanceAdaptiveTimer( - scheduledAt: self.adaptiveRefreshScheduledAt, - candidate: candidate) - else { return } - self.startTimer(preservingResetBoundaryRefresh: true) + self.advanceAdaptiveTimerIfEarlier(at: date) } } diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 0aa0343de3..e5a9252c80 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -60,10 +60,22 @@ public struct AgentSession: Codable, Equatable, Sendable, Identifiable { public struct SessionScanConfig: Equatable, Sendable { public var activeWindow: TimeInterval public var fileOnlyWindow: TimeInterval + public var maxProcessCount: Int + public var maxCodexRolloutCount: Int + public var maxClaudeTranscriptCountPerProject: Int - public init(activeWindow: TimeInterval = 120, fileOnlyWindow: TimeInterval = 30 * 60) { + public init( + activeWindow: TimeInterval = 120, + fileOnlyWindow: TimeInterval = 30 * 60, + maxProcessCount: Int = 64, + maxCodexRolloutCount: Int = 128, + maxClaudeTranscriptCountPerProject: Int = 64) + { self.activeWindow = activeWindow self.fileOnlyWindow = fileOnlyWindow + self.maxProcessCount = maxProcessCount + self.maxCodexRolloutCount = maxCodexRolloutCount + self.maxClaudeTranscriptCountPerProject = maxClaudeTranscriptCountPerProject } public func state(lastActivityAt: Date?, now: Date, hasLiveProcess: Bool) -> AgentSession.State { @@ -270,9 +282,10 @@ public enum ClaudeSessionProjectMapper { public static func transcripts( cwd: String, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, - fileManager: FileManager = .default) -> [Transcript] + fileManager: FileManager = .default, + limit: Int? = nil) -> [Transcript] { - self.projectDirectories(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager) + let transcripts = self.projectDirectories(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager) .flatMap { directory -> [(URL, Date)] in guard let files = try? fileManager.contentsOfDirectory( at: directory, @@ -289,6 +302,8 @@ public enum ClaudeSessionProjectMapper { } .sorted { $0.1 > $1.1 } .map { Transcript(url: $0.0, modifiedAt: $0.1) } + guard let limit else { return transcripts } + return Array(transcripts.prefix(max(0, limit))) } } @@ -297,7 +312,9 @@ public enum AgentSessionCorrelation { processes.sorted { lhs, rhs in let lhsDate = lhs.startedAt ?? .distantPast let rhsDate = rhs.startedAt ?? .distantPast - if lhsDate != rhsDate { return lhsDate > rhsDate } + if lhsDate != rhsDate { + return lhsDate > rhsDate + } return lhs.pid > rhs.pid } } diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 6b1bd2273a..5ba2d0eae8 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -19,14 +19,16 @@ public struct LocalAgentSessionScanner: Sendable { self.config = config } + @concurrent public func scan( now: Date = Date(), environment: [String: String] = ProcessInfo.processInfo.environment) async -> [AgentSession] { let processOutput = await self.processOutput(environment: environment) let allProcesses = AgentPSOutputParser.parse(processOutput) - let processes = AgentSessionCorrelation.newestProcessesFirst( + let processes = Array(AgentSessionCorrelation.newestProcessesFirst( AgentPSOutputParser.agentProcesses(from: allProcesses)) + .prefix(max(0, self.config.maxProcessCount))) let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) let cwdByPID = await self.cwdByPID(processes.map(\ .pid), environment: environment) let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) @@ -55,7 +57,10 @@ public struct LocalAgentSessionScanner: Sendable { let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude } let claudeCWDs = Set(claudeProcesses.compactMap { cwdByPID[$0.pid] }) let claudeTranscriptsByCWD = Dictionary(uniqueKeysWithValues: claudeCWDs.map { cwd in - (cwd, ClaudeSessionProjectMapper.transcripts(cwd: cwd, homeDirectory: context.homeDirectory)) + (cwd, ClaudeSessionProjectMapper.transcripts( + cwd: cwd, + homeDirectory: context.homeDirectory, + limit: self.config.maxClaudeTranscriptCountPerProject)) }) let claudeTranscripts = AgentSessionCorrelation.assignClaudeTranscripts( processes: claudeProcesses, @@ -193,7 +198,7 @@ public struct LocalAgentSessionScanner: Sendable { formatter.dateFormat = "yyyy/MM/dd" let fileManager = FileManager.default - return days.flatMap { day -> [Rollout] in + let candidates = days.flatMap { day -> [(url: URL, modifiedAt: Date)] in let directory = root.appendingPathComponent(formatter.string(from: day), isDirectory: true) guard let files = try? fileManager.contentsOfDirectory( at: directory, @@ -203,12 +208,18 @@ public struct LocalAgentSessionScanner: Sendable { return files.compactMap { file in guard file.lastPathComponent.hasPrefix("rollout-"), file.pathExtension == "jsonl", let modifiedAt = try? file.resourceValues( - forKeys: [.contentModificationDateKey]).contentModificationDate, - let metadata = CodexRolloutFirstLineParser.read(from: file) + forKeys: [.contentModificationDateKey]).contentModificationDate else { return nil } - return Rollout(url: file, modifiedAt: modifiedAt, metadata: metadata) + return (file, modifiedAt) } }.sorted { $0.modifiedAt > $1.modifiedAt } + + return candidates + .prefix(max(0, self.config.maxCodexRolloutCount)) + .compactMap { candidate in + guard let metadata = CodexRolloutFirstLineParser.read(from: candidate.url) else { return nil } + return Rollout(url: candidate.url, modifiedAt: candidate.modifiedAt, metadata: metadata) + } } private func findExecutable(_ name: String, environment: [String: String]) -> String? { diff --git a/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift b/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift index beb3b09167..d0a03c1237 100644 --- a/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift +++ b/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift @@ -44,4 +44,16 @@ struct CLIArgumentsTests { } #expect(policyNames == ReplayPolicyName.allCases) } + + @Test + func `released activity candidate name remains an alias for production adaptive`() { + let arguments = CLIArguments.parse(["trace.jsonl", "--policy", "adaptive-activity"]) + + guard case let .run(_, policyNames, _, _) = arguments else { + Issue.record("Expected the released alias to remain accepted") + return + } + #expect(policyNames == [.adaptive]) + #expect(policyNames.map(\.policy.name) == ["adaptive"]) + } } diff --git a/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift b/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift index 7bbdc50397..75d1a06a2e 100644 --- a/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift +++ b/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift @@ -7,6 +7,7 @@ struct AdaptiveRefreshPolicyCoreTests { private func input( ageSeconds: TimeInterval?, + codingActivityAgeSeconds: TimeInterval? = nil, lowPowerModeEnabled: Bool = false, thermalPressure: AdaptiveRefreshPolicyCore.ThermalPressure = .nominal) -> AdaptiveRefreshPolicyCore.Input @@ -14,6 +15,7 @@ struct AdaptiveRefreshPolicyCoreTests { AdaptiveRefreshPolicyCore.Input( now: Self.referenceNow, lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lastCodingActivityAt: codingActivityAgeSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, lowPowerModeEnabled: lowPowerModeEnabled, thermalPressure: thermalPressure) } @@ -66,6 +68,49 @@ struct AdaptiveRefreshPolicyCoreTests { #expect(decision.delay == .seconds(30 * 60)) } + @Test(arguments: [TimeInterval(3601), 14400, 100_000]) + func `recent coding activity caps slower menu decisions at five minutes`(ageSeconds: TimeInterval) { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: ageSeconds, + codingActivityAgeSeconds: 0)) + #expect(decision.reason == .codingActivity) + #expect(decision.delay == .seconds(5 * 60)) + } + + @Test + func `coding activity does not lengthen recent or warm decisions`() { + let recent = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: 0, + codingActivityAgeSeconds: 0)) + let warm = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: 301, + codingActivityAgeSeconds: 0)) + #expect(recent.reason == .recentInteraction) + #expect(recent.delay == .seconds(2 * 60)) + #expect(warm.reason == .warm) + #expect(warm.delay == .seconds(5 * 60)) + } + + @Test + func `constraints win and the coding activity boundary is exclusive`() { + let constrained = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 0, + lowPowerModeEnabled: true)) + let insideBoundary = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 299)) + let atBoundary = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 300)) + #expect(constrained.reason == .constrained) + #expect(constrained.delay == .seconds(30 * 60)) + #expect(insideBoundary.reason == .codingActivity) + #expect(insideBoundary.delay == .seconds(5 * 60)) + #expect(atBoundary.reason == .longIdle) + #expect(atBoundary.delay == .seconds(30 * 60)) + } + @Test func `future timestamps read as recent`() { let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: -1_000_000)) diff --git a/Tests/AdaptiveReplayKitTests/CodingActivityReplayPolicyTests.swift b/Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift similarity index 82% rename from Tests/AdaptiveReplayKitTests/CodingActivityReplayPolicyTests.swift rename to Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift index 28f84cc412..c089f05d68 100644 --- a/Tests/AdaptiveReplayKitTests/CodingActivityReplayPolicyTests.swift +++ b/Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift @@ -2,7 +2,7 @@ import AdaptiveReplayKit import Foundation import Testing -struct CodingActivityReplayPolicyTests { +struct MenuOnlyAdaptivePolicyTests { private static let now = Date(timeIntervalSinceReferenceDate: 10000) private func input( @@ -19,27 +19,30 @@ struct CodingActivityReplayPolicyTests { } @Test - func `active coding caps idle and long-idle decisions at five minutes`() { - let policy = CodingActivityAdaptivePolicy() + func `production adaptive caps idle and long-idle decisions during coding`() { + let policy = AdaptiveReplayPolicy() - #expect(policy.name == "adaptive-activity") + #expect(policy.name == "adaptive") #expect(policy.decide(self.input(menuAge: 2 * 3600, activityAge: 10)).delaySeconds == 300) #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 300) #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "codingActivity") } @Test - func `existing recent and warm decisions remain unchanged`() { - let policy = CodingActivityAdaptivePolicy() + func `menu-only baseline preserves the pre-activity policy`() { + let policy = MenuOnlyAdaptivePolicy() - #expect(policy.decide(self.input(menuAge: 60, activityAge: 10)).delaySeconds == 120) - #expect(policy.decide(self.input(menuAge: 600, activityAge: 10)).delaySeconds == 300) + #expect(policy.name == "adaptive-menu-only") + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 1800) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "longIdle") } @Test - func `constrained state wins and exact threshold is inactive`() { - let policy = CodingActivityAdaptivePolicy() + func `production adaptive preserves recent warm constrained and boundary decisions`() { + let policy = AdaptiveReplayPolicy() + #expect(policy.decide(self.input(menuAge: 60, activityAge: 10)).delaySeconds == 120) + #expect(policy.decide(self.input(menuAge: 600, activityAge: 10)).delaySeconds == 300) #expect(policy.decide(self.input(menuAge: nil, activityAge: 10, constrained: true)).delaySeconds == 1800) #expect(policy.decide(self.input(menuAge: nil, activityAge: 300)).delaySeconds == 1800) #expect(policy.decide(self.input(menuAge: nil, activityAge: nil)).delaySeconds == 1800) @@ -65,7 +68,7 @@ struct CodingActivityReplayPolicyTests { codexActivitySeconds: 0), ] - let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy()) + let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy()) #expect(metrics.codingActiveDecisionCount == 0) #expect(metrics.totalRefreshCount == 0) @@ -93,7 +96,7 @@ struct CodingActivityReplayPolicyTests { .refreshCompleted(timestamp: Self.now.addingTimeInterval(240)), ] - let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy()) + let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy()) #expect(metrics.totalRefreshCount == 2) #expect(metrics.codingActiveDecisionCount == 1) @@ -113,7 +116,7 @@ struct CodingActivityReplayPolicyTests { .refreshCompleted(timestamp: Self.now.addingTimeInterval(1800)), ] - let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy()) + let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy()) #expect(metrics.codingActiveDecisionCount == 0) #expect(metrics.codingActiveDelayViolationCount == 0) diff --git a/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift b/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift index 9964757a9e..cf50c89ec1 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift @@ -7,12 +7,14 @@ struct AdaptiveRefreshPolicyTests { private func decision( ageSeconds: TimeInterval? = 0, + codingActivityAgeSeconds: TimeInterval? = nil, lowPowerModeEnabled: Bool, thermalState: ProcessInfo.ThermalState) -> AdaptiveRefreshPolicy.Decision { UsageStore.adaptiveRefreshDecision( now: Self.referenceNow, lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lastCodingActivityAt: codingActivityAgeSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, lowPowerModeEnabled: lowPowerModeEnabled, thermalState: thermalState) } @@ -58,4 +60,15 @@ struct AdaptiveRefreshPolicyTests { #expect(noHistory.reason == .longIdle) #expect(noHistory.delay == .seconds(30 * 60)) } + + @Test + func `app adapter forwards coding activity into the shared core`() { + let decision = self.decision( + ageSeconds: nil, + codingActivityAgeSeconds: 0, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(decision.reason == .codingActivity) + #expect(decision.delay == .seconds(5 * 60)) + } } diff --git a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift index 5630fa60ee..b21cf5e1aa 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift @@ -75,6 +75,36 @@ struct AdaptiveRefreshTimerTests { #expect(store.adaptiveRefreshScheduledAt == interactionSchedule) } + @Test + func `coding activity advances a long idle timer without postponing an earlier tick`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-activity-advance", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } + + let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt) + let observedAt = Date() + store.noteCodingActivityObserved(at: observedAt, now: observedAt) + try await Self.waitUntil { + guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false } + return scheduledAt < longIdleSchedule + } + let activitySchedule = try #require(store.adaptiveRefreshScheduledAt) + #expect(store.lastCodingActivityAt == observedAt) + + // An older observation is ignored. A newer observation is retained, but cannot push an + // already earlier provider refresh later. + store.noteCodingActivityObserved( + at: observedAt.addingTimeInterval(-1), + now: observedAt.addingTimeInterval(30)) + #expect(store.lastCodingActivityAt == observedAt) + store.noteCodingActivityObserved( + at: observedAt.addingTimeInterval(1), + now: observedAt.addingTimeInterval(30)) + #expect(store.lastCodingActivityAt == observedAt.addingTimeInterval(1)) + #expect(store.adaptiveRefreshScheduledAt == activitySchedule) + } + @Test func `noting a menu open records the signal without starting a refresh`() { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-noteMenuOpened", frequency: .manual) @@ -90,6 +120,35 @@ struct AdaptiveRefreshTimerTests { #expect(store.isRefreshing == false) } + @Test + func `noting coding activity in fixed mode records only the in-memory signal`() { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-noteCodingActivity", + frequency: .fiveMinutes) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + let observedAt = Date() + + store.noteCodingActivityObserved(at: observedAt) + + #expect(store.lastCodingActivityAt == observedAt) + #expect(store.adaptiveRefreshScheduledAt == nil) + #expect(store.completedRefreshCountForTesting == 0) + } + + @Test + func `clearing coding activity removes the adaptive input`() { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-clearCodingActivity", + frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.noteCodingActivityObserved(at: Date(timeIntervalSinceReferenceDate: 100)) + #expect(store.lastCodingActivityAt != nil) + + store.clearCodingActivityObservation() + + #expect(store.lastCodingActivityAt == nil) + } + @Test func `opportunistic timer refresh is a no-op while another refresh is already in flight`() async { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-coalesce", frequency: .manual) diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift index d6c34cac1d..8389175310 100644 --- a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -39,6 +39,96 @@ struct AgentSessionMenuDescriptorTests { }) } + @Test + func `adaptive refresh requires consent for local monitoring`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-adaptive-monitoring") + settings.agentSessionsEnabled = false + settings.refreshFrequency = .adaptive + let sessions = AgentSessionsStore(settings: settings) + + #expect(!sessions.localMonitoringEnabled) + settings.adaptiveActivityScanConsent = .allowed + #expect(sessions.localMonitoringEnabled) + #expect(settings.agentSessionsEnabled == false) + + settings.adaptiveActivityScanConsent = .declined + #expect(!sessions.localMonitoringEnabled) + + settings.refreshFrequency = .fiveMinutes + #expect(!sessions.localMonitoringEnabled) + + settings.agentSessionsEnabled = true + #expect(sessions.localMonitoringEnabled) + } + + @Test + func `adaptive-only scan retains a timestamp but not session details`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-adaptive-projection") + settings.agentSessionsEnabled = false + settings.refreshFrequency = .adaptive + settings.adaptiveActivityScanConsent = .allowed + let store = AgentSessionsStore(settings: settings) + let older = Date(timeIntervalSinceReferenceDate: 100) + let newer = Date(timeIntervalSinceReferenceDate: 200) + let sessions = [ + Self.session(id: "older", host: "local", activity: older), + Self.session(id: "unknown", host: "local", activity: nil), + Self.session(id: "newer", host: "local", activity: newer), + ] + + store.applyLocalScanResult(sessions, updatedAt: newer) + + #expect(store.latestLocalActivityAt == newer) + #expect(store.localSessions.isEmpty) + #expect(store.lastUpdatedAt == newer) + } + + @Test + func `adaptive-only local scan pauses under power and thermal constraints`() { + #expect(AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: false, + thermalState: .nominal)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: true, + thermalState: .nominal)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: false, + thermalState: .serious)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: false, + lowPowerModeEnabled: false, + thermalState: .nominal)) + #expect(AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: true, + adaptiveActivityScanningEnabled: false, + lowPowerModeEnabled: true, + thermalState: .critical)) + } + + @Test + func `revoking adaptive consent clears retained activity`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-consent-revoked") + settings.refreshFrequency = .adaptive + settings.adaptiveActivityScanConsent = .allowed + let store = AgentSessionsStore(settings: settings) + store.applyLocalScanResult( + [Self.session(id: "local", host: "local", activity: Date())]) + #expect(store.latestLocalActivityAt != nil) + + settings.adaptiveActivityScanConsent = .declined + store.settingsDidChange(remoteConfigurationChanged: false) + + #expect(store.latestLocalActivityAt == nil) + #expect(store.localSessions.isEmpty) + } + @Test func `session section counts groups and renders unreachable hosts`() { let now = Date(timeIntervalSince1970: 1000) @@ -160,7 +250,7 @@ struct AgentSessionMenuDescriptorTests { #expect(Self.remotePassCount(for: .settingsChangeDuringFlight) == 2) } - private static func session(id: String, host: String, activity: Date) -> AgentSession { + private static func session(id: String, host: String, activity: Date?) -> AgentSession { AgentSession( id: id, provider: .codex, diff --git a/Tests/CodexBarTests/ClaudeSessionMappingTests.swift b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift index 779691c4bb..c18835771f 100644 --- a/Tests/CodexBarTests/ClaudeSessionMappingTests.swift +++ b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift @@ -32,5 +32,8 @@ struct ClaudeSessionMappingTests { let match = try #require(ClaudeSessionProjectMapper.newestTranscript(cwd: cwd, homeDirectory: home)) #expect(match.url.lastPathComponent == "newer.jsonl") #expect(match.modifiedAt == Date(timeIntervalSince1970: 200)) + + let bounded = ClaudeSessionProjectMapper.transcripts(cwd: cwd, homeDirectory: home, limit: 1) + #expect(bounded.map(\.url.lastPathComponent) == ["newer.jsonl"]) } } diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index 6e474a0a99..7d75d510a1 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -62,4 +62,48 @@ struct CodexSessionRolloutTests { #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch(nil, nil)) #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) } + + @Test + func `local scanner parses only its newest configured rollout candidates`() async throws { + let fileManager = FileManager.default + let temporaryRoot = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryRoot) } + + let now = Date() + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = temporaryRoot.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + let fixtureURL = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let fixture = try String(contentsOf: fixtureURL, encoding: .utf8) + for (index, age) in [30.0, 20.0, 10.0].enumerated() { + let id = "bounded-rollout-\(index)" + let url = sessionDirectory.appendingPathComponent("rollout-bounded-\(index).jsonl") + try fixture + .replacingOccurrences(of: "019f-session-fixture", with: id) + .write(to: url, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.modificationDate: now.addingTimeInterval(-age)], + ofItemAtPath: url.path) + } + + let scanner = LocalAgentSessionScanner(config: SessionScanConfig( + fileOnlyWindow: 60 * 60, + maxProcessCount: 0, + maxCodexRolloutCount: 2, + maxClaudeTranscriptCountPerProject: 0)) + let sessions = await scanner.scan(now: now, environment: [ + "CODEX_HOME": codexHome.path, + "HOME": temporaryRoot.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + + #expect(Set(sessions.map(\.id)) == ["bounded-rollout-1", "bounded-rollout-2"]) + } } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 601024631e..7079c6fc74 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -85,6 +85,31 @@ struct LocalizationLanguageCatalogTests { #expect(AppLanguage.galician.rawValue == "gl") } + @Test + func `adaptive activity consent is localized in every app language`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let keys = [ + "adaptive_activity_consent_title", + "adaptive_activity_consent_message", + "adaptive_activity_consent_allow", + "adaptive_activity_consent_decline", + "adaptive_activity_scan_title", + "adaptive_activity_scan_subtitle", + ] + + for language in AppLanguage.allCases where language != .system { + let url = resourcesURL.appendingPathComponent("\(language.rawValue).lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for key in keys { + #expect(catalog[key]?.isEmpty == false, "\(language.rawValue).\(key)") + } + } + } + @Test func `language picker labels use stable native names`() { let expected: [AppLanguage: String] = [ diff --git a/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift b/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift new file mode 100644 index 0000000000..a903e176f2 --- /dev/null +++ b/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift @@ -0,0 +1,220 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct SettingsStoreRefreshDefaultTests { + enum PreviousLaunchMarker: CaseIterable, Sendable { + case providerDetection + case appGroupMigration + + func seed(_ defaults: UserDefaults) { + switch self { + case .providerDetection: + defaults.set(true, forKey: "providerDetectionCompleted") + case .appGroupMigration: + defaults.set(AppGroupSupport.migrationVersion, forKey: AppGroupSupport.migrationVersionKey) + } + } + } + + @Test + func `fresh install defaults to adaptive and persists the choice`() throws { + let suite = "SettingsStoreRefreshDefaultTests-fresh" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = self.makeStore(defaults: defaults, configStore: configStore) + + #expect(store.refreshFrequency == .adaptive) + #expect(store.refreshFrequency.seconds == nil) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.adaptive.rawValue) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + defaults.set(true, forKey: "providerDetectionCompleted") + let reloaded = self.makeStore(defaults: defaults, configStore: configStore) + #expect(reloaded.refreshFrequency == .adaptive) + #expect(reloaded.adaptiveActivityScanConsent == .undecided) + } + + @Test + func `unrecognized refresh frequency keeps the legacy fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-invalid" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("legacyValue", forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test(arguments: PreviousLaunchMarker.allCases) + func `legacy unset refresh keeps five minute fallback`(marker: PreviousLaunchMarker) throws { + let suite = "SettingsStoreRefreshDefaultTests-legacy-\(marker)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + marker.seed(defaults) + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `existing config without launch markers keeps five minute fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-existing-config" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig.makeDefault()) + + let store = self.makeStore(defaults: defaults, configStore: configStore) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `non string refresh value keeps five minute fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-non-string" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(17, forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `every valid stored refresh frequency remains authoritative`() throws { + let markers: [PreviousLaunchMarker?] = [nil, .providerDetection, .appGroupMigration] + for frequency in RefreshFrequency.allCases { + for marker in markers { + let suite = "SettingsStoreRefreshDefaultTests-valid-\(frequency.rawValue)-\(String(describing: marker))" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(frequency.rawValue, forKey: "refreshFrequency") + marker?.seed(defaults) + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == frequency) + #expect(defaults.string(forKey: "refreshFrequency") == frequency.rawValue) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + } + } + } + + @Test + func `adaptive activity consent is explicit and persists`() throws { + let suite = "SettingsStoreRefreshDefaultTests-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = self.makeStore(defaults: defaults, configStore: configStore) + + #expect(!store.adaptiveActivityScanningEnabled) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + store.adaptiveActivityScanConsent = .allowed + #expect(store.adaptiveActivityScanningEnabled) + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "allowed") + + let reloaded = self.makeStore(defaults: defaults, configStore: configStore) + #expect(reloaded.adaptiveActivityScanConsent == .allowed) + #expect(reloaded.adaptiveActivityScanningEnabled) + + reloaded.adaptiveActivityScanConsent = .declined + #expect(!reloaded.adaptiveActivityScanningEnabled) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "declined") + } + + @Test + func `invalid consent fails closed and requests a decision`() throws { + let suite = "SettingsStoreRefreshDefaultTests-invalid-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(RefreshFrequency.adaptive.rawValue, forKey: "refreshFrequency") + defaults.set("legacy", forKey: "adaptiveActivityScanConsent") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(!store.adaptiveActivityScanningEnabled) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + } + + @Test + func `existing adaptive install requires explicit consent before scanning`() throws { + let suite = "SettingsStoreRefreshDefaultTests-existing-adaptive-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + defaults.set(RefreshFrequency.adaptive.rawValue, forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .adaptive) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(!store.adaptiveActivityScanningEnabled) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + } + + @Test + func `consent prompt is limited to adaptive without Agent Sessions`() throws { + let suite = "SettingsStoreRefreshDefaultTests-consent-prompt" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + store.refreshFrequency = .fiveMinutes + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + + store.refreshFrequency = .adaptive + store.agentSessionsEnabled = true + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + + store.agentSessionsEnabled = false + #expect(store.shouldRequestAdaptiveActivityScanConsent) + } + + private func makeStore( + defaults: UserDefaults, + configStore: CodexBarConfigStore) -> SettingsStore + { + SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index e2face6bf6..d10f83d0c9 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -42,42 +42,6 @@ struct SettingsStoreTests { } } - @Test - func `default refresh frequency is five minutes`() throws { - let suite = "SettingsStoreTests-default" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - - let store = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - - #expect(store.refreshFrequency == .fiveMinutes) - #expect(store.refreshFrequency.seconds == 300) - #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) - } - - @Test - func `repairs unrecognized refresh frequency raw value`() throws { - let suite = "SettingsStoreTests-invalid-refresh" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - defaults.set("legacyValue", forKey: "refreshFrequency") - let configStore = testConfigStore(suiteName: suite) - - let store = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - - #expect(store.refreshFrequency == .fiveMinutes) - #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) - } - @Test func `persists refresh frequency across instances`() throws { let suite = "SettingsStoreTests-persist" @@ -103,6 +67,24 @@ struct SettingsStoreTests { #expect(storeB.refreshFrequency.seconds == 900) } + @Test + func `preserves an explicit five minute selection under the adaptive default`() throws { + let suite = "SettingsStoreTests-explicit-five-minute" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(RefreshFrequency.fiveMinutes.rawValue, forKey: "refreshFrequency") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(store.refreshFrequency.seconds == 300) + } + @Test func `refresh on open defaults off and persists`() throws { let suite = "SettingsStoreTests-refresh-on-open" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c4b62740e6..544092eb93 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -63,7 +63,8 @@ browser-cookie web path. The web path reuses cached cookies when possible and im the cache is missing or rejected. ### Refresh Frequency -- Default: Every 5 minutes (configurable in Preferences → General) +- Fresh-install default: Adaptive, between 2 and 30 minutes (configurable in Preferences → General). Existing installs + without a stored cadence retain the legacy 5-minute fallback. - Minimum: 1 minute - Cookie import happens automatically when cached cookies need refresh diff --git a/docs/predictive-refresh-policy.md b/docs/predictive-refresh-policy.md index 617aeea4ae..de67610cf2 100644 --- a/docs/predictive-refresh-policy.md +++ b/docs/predictive-refresh-policy.md @@ -1,5 +1,5 @@ --- -summary: "Decision record for an optional deterministic adaptive refresh cadence." +summary: "Decision record for the deterministic agent-aware adaptive refresh cadence." read_when: - Planning refresh cadence or background provider updates - Evaluating adaptive or predictive refresh behavior @@ -8,26 +8,36 @@ read_when: # Adaptive refresh decision record -- **Status:** Accepted and implemented as an opt-in mode in [#1861](https://github.com/steipete/CodexBar/pull/1861) +- **Status:** Opt-in policy accepted in [#1861](https://github.com/steipete/CodexBar/pull/1861); agent-aware fresh-install default extension implemented with the evidence below - **Decision owner:** Maintainer -- **Runtime impact:** Bounded opt-in 2–30-minute provider-batch cadence +- **Runtime impact:** Bounded fresh-install default of 2–30-minute provider-batch cadence; an explicitly allowed local activity scan runs every 30 seconds when unconstrained ## Decision -CodexBar may offer an opt-in `Adaptive` refresh frequency that adjusts the existing provider-batch timer between 2 and -30 minutes using the deterministic policy below. Do not implement the broader -per-account prediction, persistent interaction history, learned ranking, or menu prewarming proposed in the original -RFC. +CodexBar uses `Adaptive` for a missing refresh preference only when no prior-launch marker or existing config exists. +The resolved value is persisted immediately, so later launches preserve that choice. Existing installations without a +stored cadence, and unrecognized stored values, resolve to the legacy 5-minute fallback. Every valid stored choice, +including Manual and each fixed interval, remains unchanged. Adaptive adjusts the existing provider-batch timer between +2 and 30 minutes. Recent local Codex or Claude transcript activity caps otherwise slower unconstrained decisions at 5 +minutes. -This approval covers the bounded design only. Runtime implementation, tests, localization, and packaged proof were -delivered separately by #1861; changing the default or adding new signals still requires new evidence and review. +The rollout boundary uses an existing config or launch markers that predate this change (`providerDetectionCompleted` +and the app-group migration version), captured before startup migrations can create them. This covers installations from +v0.4 onward plus any installation with a config. A completely untouched, configless v0.1-v0.3 installation leaves no +durable signal that can distinguish it from a new install; that historical cohort follows the fresh-install default. +Selecting a fixed cadence or Manual remains authoritative. + +The original #1861 decision approved a menu-only opt-in policy. The 2026-07-12 extension below adds one local, +in-memory activity timestamp and changes the fallback after the offline replay, timer integration, privacy projection, +and scanner-cost proof recorded here. It does not approve per-account prediction, persistent interaction history, +learned ranking, or menu prewarming. ## Options considered | Option | Freshness | Complexity | Provider work | Decision | |---|---|---|---|---| | Keep fixed frequencies only | Predictable | Lowest | Predictable | Safe fallback | -| Add bounded adaptive batch cadence | Better while active; quieter while idle | Small | Bounded | Recommended experiment | +| Use bounded agent-aware adaptive batch cadence as the fresh-install default | Better while active; quieter while idle | Small | Bounded | Selected | | Add per-provider/account prediction | Potentially best | High | Harder to reason about | Reject for now | | Add learned ranking or contextual bandits | Unproven | Very high | Harder to audit | Reject | @@ -51,20 +61,29 @@ Relevant implementation seams: app adapter and offline replay tooling. - `Sources/CodexBar/SettingsStore.swift`: `RefreshFrequency` and fixed interval mapping. - `Sources/CodexBar/UsageStore.swift`: timer ownership and provider-batch refresh. +- `Sources/CodexBar/AgentSessionsStore.swift`: 30-second local scan ownership and timestamp-only Adaptive projection. +- `Sources/CodexBarCore/LocalAgentSessionScanner.swift`: existing local process/transcript correlation reused by + Agent Sessions and Adaptive. - `Sources/CodexBar/UsageStore+Refresh.swift`: provider refresh coalescing and result application. - `Sources/CodexBar/StatusItemController+Menu.swift`: missing/error-only menu-open refresh. - `Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift`: deferred non-interactive refresh safety. - `Tests/CodexBarTests/StatusMenuInstantOpenTests.swift`: fresh, missing, in-flight, and close-during-refresh contracts. -The adaptive experiment must change only the first path. It must not alter the menu-open setting, its default, provider -selection, interaction context, or promise that menu-open refresh does not reset the periodic refresh clock. +Adaptive changes only the first provider-refresh path. The local scanner supplies an in-memory scheduling signal; it +does not fetch provider usage. The change does not alter the menu-open setting, its default, provider selection, +interaction context, or the promise that menu-open refresh does not reset the periodic refresh clock. ## Accepted product contract -- Add `Adaptive` as a mutually exclusive `RefreshFrequency` choice. -- Keep `5 minutes` as the default for new and existing users. -- Never migrate an existing fixed selection to `Adaptive`. -- Preserve `Manual` and every existing fixed interval exactly. +- Keep `Adaptive` as a mutually exclusive `RefreshFrequency` choice and use it for an unset cadence only when no + prior-launch marker or existing config exists. +- Preserve the old implicit 5-minute fallback for existing installations without a stored cadence and for unrecognized + stored values. Persist either resolved fallback immediately. +- Preserve every valid stored value exactly, including `Manual`, every fixed interval, and `Adaptive`. +- Do not treat an Adaptive cadence as authorization to inspect local process or session metadata. Persist an explicit + `undecided`, `allowed`, or `declined` choice; only `allowed` enables Adaptive-only scans. +- Present the choice once when Adaptive is effective and consent is undecided. Both answers keep Adaptive selected; + declining retains the menu-only policy. Existing Adaptive users remain unscanned until they explicitly allow it. - Schedule the same enabled-provider batch as fixed refresh; do not select accounts, workspaces, or data lanes. - Keep manual refresh immediate and user-initiated. - When refresh-all-on-open is disabled, keep menu-open refresh missing/error-only and background/non-interactive. @@ -72,6 +91,10 @@ selection, interaction context, or promise that menu-open refresh does not reset not itself fetch, cancel the periodic timer, or count a menu-originated refresh as an adaptive timer tick. - Never make an automatic provider, account, workspace, or credential-source selection. - Never bypass provider-specific auth, prompt, coalescing, or failure gates. +- Use only local Agent Sessions activity for scheduling. Remote discovery, Tailscale, and SSH remain behind the + explicit Agent Sessions setting. +- When the Agent Sessions UI is off, discard scanned PID, CWD, project, transcript path, and session identity fields; + retain only the newest activity timestamp in memory. ## Deterministic policy @@ -82,12 +105,14 @@ struct AdaptiveRefreshPolicy: Sendable { struct Input: Sendable, Equatable { let now: Date let lastMenuOpenAt: Date? + let lastCodingActivityAt: Date? let lowPowerModeEnabled: Bool let thermalState: ProcessInfo.ThermalState } enum Reason: String, Sendable { case recentInteraction + case codingActivity case warm case idle case longIdle @@ -110,6 +135,7 @@ Policy table, evaluated after startup and after every completed or skipped timer | Low Power Mode or serious/critical thermal state | 30 minutes | `constrained` | | Menu opened at or after 5 minutes ago, including a future clock-adjusted timestamp | 2 minutes | `recentInteraction` | | Menu opened more than 5 minutes and at most 1 hour ago | 5 minutes | `warm` | +| Local coding activity observed less than 5 minutes ago and the menu-only result would be slower | 5 minutes | `codingActivity` | | Menu opened more than 1 hour and less than 4 hours ago | 15 minutes | `idle` | | No menu open recorded, or last open at least 4 hours ago | 30 minutes | `longIdle` | @@ -122,9 +148,9 @@ Bounds are part of the contract: - canceled timers do not launch work; - settings changes cancel and replace the pending timer. -The policy deliberately excludes quota level, provider latency, error count, account choice, time-of-day, and content -change rate. Those signals require new durable state or provider-specific semantics and do not belong in the first -experiment. +The policy deliberately excludes quota level, provider latency, error count, account choice, time-of-day, transcript +contents, and content-change rate. Those signals require durable state or provider-specific semantics. The activity +input is only the newest local transcript modification time already derived by the Agent Sessions scanner. ## Scheduler integration @@ -139,6 +165,8 @@ Keep scheduling inside `UsageStore`; do not add a second scheduler abstraction. 4. Recompute the next delay after the batch completes. 5. Record menu-open time through a minimal callback owned by `UsageStore` or a dedicated in-memory signal object. Do not couple the policy to `NSMenu`, menu descriptors, account switchers, or rendering state. +6. Feed the newest local transcript activity timestamp from `AgentSessionsStore` into `UsageStore`. A new observation + may replace a pending Adaptive sleep only when its candidate refresh is earlier. `NSBackgroundActivityScheduler` is out of scope. Current refresh choices include intervals below the range where that API is intended to help, and using two scheduling mechanisms would make cancellation and exact timing harder to audit. @@ -146,13 +174,25 @@ Revisit it only with separate energy measurements and a design for launch/relaun ## State, privacy, and observability -The first experiment stores no interaction history. +Adaptive stores no persistent interaction history. -- Keep `lastMenuOpenAt` in memory; reset it on launch. +- Keep `lastMenuOpenAt` and `lastCodingActivityAt` in memory; reset both on launch. - Read Low Power Mode and thermal state at decision time. - Log only the selected delay and stable `Reason` code through the existing local logger. -- Do not log or store provider identity, account identity, email, workspace, path, credentials, response data, or menu - content for scheduling. +- After explicit consent, reuse the existing local scanner every 30 seconds. It inspects the running-process list and + command lines via `ps`, runs `lsof` when needed, and enumerates recent Codex + rollouts; reads rollout first-line metadata and mtimes; and inspects Claude transcript metadata. This is a local + metadata scan, not a provider request. Pause Adaptive-only scans under Low Power Mode or serious/critical thermal + pressure; keep scanning when the user explicitly enables Agent Sessions presentation. +- Bound each scan to the newest 64 agent processes, 128 Codex rollout metadata records, and 64 Claude transcript + candidates per project. Directory metadata enumeration still scales with those known session directories, but file + content parsing and process/transcript correlation do not grow without limit. +- When Agent Sessions presentation is disabled, discard the full scan result after deriving the newest `Date`. Do not + retain or publish its PID, CWD, project, transcript path, or session identity fields. +- Do not log or persist provider identity, account identity, email, workspace, path, credentials, response data, menu + content, or the activity timestamp for scheduling. +- Do not invoke remote host discovery, Tailscale, or SSH unless Agent Sessions is explicitly enabled. +- Clear the in-memory activity timestamp when consent is revoked so it cannot continue influencing later decisions. - Do not add analytics or send refresh-policy data off device. This avoids a new retention policy, migration, deletion UI, and behavioral profile. Persistent history requires a new @@ -175,14 +215,18 @@ need a separate contract for partial batch success. ## Implementation sequence -Each step should be independently reviewable: +The work remained independently reviewable: 1. Add the pure policy and table-driven tests; no settings or runtime changes. -2. Add the `Adaptive` setting and localization, default unchanged. +2. Add the `Adaptive` setting and localization as an opt-in choice. 3. Teach the existing timer to request fixed/manual/adaptive delays. 4. Wire the in-memory menu-open signal without changing `scheduleOpenMenuRefresh(for:)`. 5. Add local reason-code logging and documentation. -6. Package and validate the opt-in mode before considering a default change. +6. Add offline replay tooling and evaluate the frozen trace in #2029. +7. Add explicit persisted consent, then reuse the local Agent Sessions scanner only after approval, project its output + to one in-memory timestamp when presentation is off, and advance only an otherwise later Adaptive timer. +8. Make Adaptive the fresh-install default after policy, timer, projection, and scanner-cost verification, while + preserving the legacy 5-minute fallback for existing unset or invalid state. Do not add target adapters, outcome databases, account/workspace prediction, learned models, visible ordering changes, or menu prewarming as part of these steps. @@ -196,6 +240,8 @@ or menu prewarming as part of these steps. - serious and critical thermal states select 30 minutes; - nominal/fair thermal states do not force the constrained branch; - future or clock-adjusted menu timestamps are treated as recent and never produce a negative delay; +- recent coding activity caps only otherwise slower decisions and never overrides a constrained 30-minute decision; +- the coding-activity threshold is exclusive at exactly 5 minutes; - every decision stays within the 2-to-30-minute bounds. ### Timer integration @@ -206,6 +252,20 @@ or menu prewarming as part of these steps. - overlapping timer ticks do not overlap `UsageStore.refresh()`; - launch with no menu history begins at 30 minutes; - menu-open signal changes the next decision but does not itself start a batch. +- a newer coding-activity observation advances a 30-minute sleep to the 5-minute cap without starting a batch; +- repeated, older, or later observations never postpone an earlier scheduled refresh; +- fixed and manual modes may record the in-memory signal but never reschedule from it. + +### Agent Sessions and consent boundary + +- Adaptive without consent keeps the historical menu-only policy and performs no local session scan; +- allowing local coding activity enables monitoring without enabling Agent Sessions presentation; +- existing Adaptive users remain unscanned while consent is undecided or declined; +- an Adaptive-only scan retains the newest attributable timestamp and discards complete session records; +- Adaptive-only scans pause under Low Power Mode and serious/critical thermal pressure; +- scanner limits cap agent processes, Codex rollout parsing, and Claude transcript candidates; +- remote fetch remains guarded by the explicit Agent Sessions setting; +- a refresh-frequency change does not invalidate or retry an in-flight remote refresh. ### Menu regression @@ -221,24 +281,26 @@ Use stubs and test stores. Do not run live providers, browser-cookie imports, or ## Acceptance and rollback -Before changing the default, a separate PR updating this decision record must provide measured evidence. Minimum -evidence: +The 2026-07-12 extension requires evidence from separate seams rather than treating one replay as end-to-end proof: -- deterministic replay tests show fewer scheduled batches than the 5-minute baseline during idle traces; -- unconstrained active decisions never schedule slower than the existing 5-minute default; Low Power Mode and - serious/critical thermal state retain the 30-minute safety override; -- no regression in menu-open responsiveness or prompt safety; -- packaged opt-in use shows understandable reason logs and no timer overlap. +- deterministic decision-point replay must show fewer scheduled batches than fixed 5 minutes, no unconstrained active + decision above 5 minutes, and unchanged 30-minute constrained decisions; +- timer integration tests must show an activity callback advancing a pending long-idle sleep without starting a batch, + postponing an earlier tick, or affecting fixed/manual scheduling; +- the Adaptive-only scan projection must discard complete session records and retain one in-memory timestamp; +- remote discovery and SSH must remain guarded by the explicit Agent Sessions setting; +- the local scanner's cost must be measured and disclosed separately from provider-batch savings. -Rollback is deleting the `Adaptive` option and policy helper. Fixed/manual scheduling and stored fixed selections remain -valid because the experiment does not migrate them or change their raw values. +Rollback is restoring the fresh-install fallback to 5 minutes and omitting `lastCodingActivityAt` from the policy input. +Existing unset/invalid state already retains the 5-minute fallback; fixed/manual scheduling and every valid stored +selection remain compatible because their raw values do not change. ## Explicit non-decisions -Approval of this document does not approve: +Approval of this extension does not approve: -- making adaptive refresh the default; - changing the refresh-all-on-open default or its existing provider/auth behavior; +- enabling Agent Sessions presentation or remote discovery by default; - per-provider, per-account, per-workspace, or per-source scheduling; - persistent interaction or outcome history; - `NSBackgroundActivityScheduler` adoption; @@ -276,3 +338,44 @@ this is not sufficient evidence for a production or default change. Replay advances are counterfactual events on a zero-service-time policy clock. They are intentionally not compared by count with live `timerAdvanced` events, whose schedule includes real refresh duration and in-flight coalescing. The offline audit reports recorded schedule events separately when the supplied trace contains them. + +## Agent-aware fresh-install default follow-up (2026-07-12) + +This extension moves the previously replay-only activity cap into `AdaptiveRefreshPolicyCore`, retains the former +menu-only policy as `adaptive-menu-only`, and uses Adaptive as the fresh-install default while preserving the legacy +5-minute fallback for existing unset or invalid preferences. The existing local Agent Sessions scanner is wired into +the live timer only after explicit consent; undecided or declined Adaptive users keep the menu-only policy. The +`adaptive-activity` CLI spelling remains an alias for the production `adaptive` policy so scripts written against +0.42.1 keep working. + +The same frozen 1,780-record trace and segmentation settings produce: + +| Policy | Simulated refreshes | Per observed 24h | Simulated advances | Unconstrained active over 5m | Menu staleness p50 / p95 | +|---|---:|---:|---:|---:|---:| +| Production adaptive | 696 | 143.88 | 53 | 0 / 145 | 139s / 1093s | +| Historical menu-only adaptive | 694 | 143.47 | 53 | 4 / 145 | 142s / 1093s | +| Fixed 5m | 1383 | 285.90 | 0 | 0 / 99 | 150s / 281s | + +On this trace, production Adaptive schedules 49.7% fewer simulated refreshes than fixed 5 minutes. Compared with the +historical menu-only policy, it adds 2 simulated refreshes (0.29%) and removes all 4 observed active-delay violations. It +does not improve p95 menu staleness. Activity fields are present on 462 of 733 decision records (63%); 185 of those 462 +samples (40%) report activity under 5 minutes old. This is one machine's trace, not a population or energy study. The +SHA-256 remains `b1e4aa33180b7c177293eb9ed16b45e24e026d259600fba2b1b67b931b904f0b`. + +Replay proves the policy table at reconstructed decision points. It does not reproduce the new 30-second scanner +callback, because the frozen trace sampled activity only on decision records. Timer integration tests separately prove +that a live activity observation can pull a pending 30-minute sleep forward and cannot postpone an earlier refresh. + +A 20-run `hyperfine` sample of exact-head `.build/debug/CodexBarCLI sessions --json`, with one attributable Codex +session, measured 153.0 ms ± 14.4 ms wall time and 134.3 ms combined user plus system CPU time per invocation. At the +30-second unconstrained cadence, that CPU figure extrapolates to 6.5 CPU-minutes per day. Adaptive-only scans pause under +Low Power Mode and serious/critical thermal pressure. The CLI process startup is included, so this is a conservative +same-machine sample for in-process work, not a scanner upper bound or general energy claim. Simulated refresh counts and +scanner CPU are reported separately; the replay does not claim net energy savings. + +An exact-head synthetic stress fixture then exercised 12 agent-like processes and 512 recent Codex rollout entries. +After bounding the scanner, 20 runs of `.build/debug/CodexBarCLI sessions --json` measured 231.4 ms ± 12.4 ms wall time +and 209.3 ms combined user plus system CPU time. At a continuous 30-second cadence that CPU figure extrapolates to 10.1 +CPU-minutes per day. The CLI startup is included. This is a stress sample rather than a population or energy claim, and +directory metadata enumeration still scales; the code-level limits bound process correlation and the number of rollout +and transcript records parsed on every scan. diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index d7384b662f..e3808187b4 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -8,9 +8,11 @@ read_when: # Refresh loop ## Cadence -- `RefreshFrequency`: Manual, 1m, 2m, 5m (default), 15m, 30m, Adaptive. -- Stored in `UserDefaults` via `SettingsStore`. The default stays 5m for new and existing users; nothing - auto-migrates an existing fixed selection to Adaptive. +- `RefreshFrequency`: Manual, 1m, 2m, 5m, 15m, 30m, Adaptive (fresh-install default). +- Stored in `UserDefaults` via `SettingsStore`. An unset cadence resolves to Adaptive only when no prior-launch marker + or existing config is present, and the resolved value is persisted immediately. Existing installations without a + stored cadence and unrecognized stored values resolve to the legacy 5-minute fallback. Every valid stored choice, + including Manual and each fixed interval, remains unchanged. ## Behavior - Background refresh runs off-main and updates `UsageStore` (usage + credits + optional web scrape). @@ -22,24 +24,39 @@ read_when: ## Adaptive mode - `AdaptiveRefreshPolicy` (`Sources/CodexBar/AdaptiveRefreshPolicy.swift`) is a pure function of an `Input` - (current time, last menu-open time, Low Power Mode, thermal state) that returns the next delay and a - stable `Reason`. It reads no clock and no `ProcessInfo` state itself — `UsageStore.startTimer()` gathers - those impure signals immediately before each tick. + (current time, last menu-open time, latest local coding-activity time, Low Power Mode, thermal state) that returns + the next delay and a stable `Reason`. It reads no clock and no `ProcessInfo` state itself; + `UsageStore.startTimer()` gathers those impure signals immediately before each tick. - Policy table, first match wins: | Condition | Delay | Reason | |---|---:|---| | Low Power Mode enabled, or thermal state `.serious`/`.critical` | 30 min | `constrained` | - | Menu opened within the last 5 min (including future/clock-adjusted timestamps) | 2 min | `recentInteraction` | - | Menu opened 5 min–1 h ago | 5 min | `warm` | + | Menu opened at most 5 min ago (including future/clock-adjusted timestamps) | 2 min | `recentInteraction` | + | Menu opened more than 5 min and at most 1 h ago | 5 min | `warm` | + | Local Codex or Claude transcript activity observed less than 5 min ago, when the menu rule would be slower | 5 min | `codingActivity` | | Menu opened 1–4 h ago | 15 min | `idle` | | No recorded menu open, or opened 4+ h ago | 30 min | `longIdle` | - Every decision falls in the 2–30 min range by construction. Deliberately excludes quota, latency, error, account, and time-of-day signals. -- `UsageStore` tracks `lastMenuOpenAt` in memory only (never persisted; resets on launch). `noteMenuOpened(at:)` - is called from `StatusItemController.menuWillOpen(_:)`. A menu open can bring a pending adaptive tick - forward to the recent-interaction cadence, but never postpones an earlier tick or refreshes synchronously. +- `UsageStore` tracks `lastMenuOpenAt` and `lastCodingActivityAt` in memory only (never persisted; both reset on + launch). A menu open or a newer local activity observation can bring a pending adaptive tick forward, but never + postpones an earlier tick or refreshes synchronously. +- Adaptive reuses `LocalAgentSessionScanner` every 30 seconds only after the user allows local coding activity. The + persisted `adaptiveActivityScanConsent` value is `undecided`, `allowed`, or `declined`; missing or invalid values are + repaired to `undecided`, which never authorizes a scan. Both choices keep the Adaptive cadence: declining uses only + menu activity, while allowing adds the local coding-activity signal. The choice is also editable in General settings. +- An allowed scan runs `ps -axo ... command=` to inspect the running-process list and identify Codex/Claude, then runs + `lsof` when needed and enumerates + recent Codex rollouts, reads rollout first-line metadata and mtimes, and inspects Claude transcript metadata. When + the Agent Sessions UI is off, CodexBar discards the resulting session records and retains only the latest `Date`. + Each scan considers at most 64 agent processes, parses at most 128 Codex rollout metadata records, and keeps + at most 64 Claude transcript candidates per project. + Adaptive-only scans pause under Low Power Mode and serious/critical thermal pressure. Explicitly enabling Agent + Sessions continues to authorize its local scan independently of the Adaptive consent choice. Tailscale discovery and + SSH remain behind the Agent Sessions setting. The activity timestamp is not persisted, logged, or uploaded, and it is + cleared when consent is revoked. - Each adaptive tick recomputes the delay after the previous refresh completes, sleeps, then calls the same `UsageStore.refresh()` used by fixed-interval mode, so the existing `isRefreshing` coalescing guard still applies — only one provider-batch refresh runs at a time regardless of cadence mode.