From 6115e1e5939b85e87e88b37683ab7da206f38976 Mon Sep 17 00:00:00 2001 From: katipally Date: Wed, 12 Aug 2026 20:16:43 -0700 Subject: [PATCH] fix(notifications): stop sleep-control and session-close spam (macOS 2.7.3) Three notifications fired without cause and had no way to switch off. Sleep control ("Keeping Mac Awake" / "Sleep Control Returned") Both were edge-triggered on the isActive flip in Auto mode, where the hold condition is an OR of three volatile signals with no hysteresis and no debounce. Every walk-away-and-come-back cycle cost two banners, and toggling Auto off and back on re-fired the first. They were also the only notifications in the app that bypassed AgentNotificationPrefs entirely, so there was no toggle to silence them. Sleep state is already live in the menu bar icon, the panel, and the iOS Dashboard via MacStatus.sleepActive, so the banners only repeated what the UI already showed. Both call sites are gone and SleepStateNotifier is deleted (it had no other callers). Session close ("Task completed" on quitting an agent) A close event (SessionEnd / sessionEnd) normalizes to the same .sessionEnd phase as a turn-end event (Stop / agentStop), so quitting an agent produced a duplicate "done" alert seconds after the real one. Split at the gate rather than in the normalizer: the phase stays .sessionEnd, so hasEnded, scheduleTerminalRevert, and session history are untouched, and IDE badges do not go stale waiting on the 10-minute sweep. shouldNotify routes close events to the default-off housekeeping switch via the new AgentNotificationCatalog.isSessionClose. Events are still recorded and visible in Activity. Turn-end alerts are unchanged. Housekeeping is added to the VS Code Copilot and Copilot CLI category lists so the toggle that re-enables close alerts is reachable there. This mirrors what opencode already does with session.deleted. macOS only. The iOS companion is unchanged. --- DoomCoder.xcodeproj/project.pbxproj | 12 +- .../AgentNotificationCatalog.swift | 23 +++- .../AgentNotificationPrefs.swift | 7 ++ .../AgentTracking/SleepStateNotifier.swift | 108 ------------------ DoomCoder/SleepManager.swift | 18 +-- docs/CHANGELOG.md | 32 ++++++ 6 files changed, 65 insertions(+), 135 deletions(-) delete mode 100644 DoomCoder/AgentTracking/SleepStateNotifier.swift diff --git a/DoomCoder.xcodeproj/project.pbxproj b/DoomCoder.xcodeproj/project.pbxproj index 64df289..510824c 100644 --- a/DoomCoder.xcodeproj/project.pbxproj +++ b/DoomCoder.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ DC0000000000002000000202 /* AgentNotificationCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000202 /* AgentNotificationCatalog.swift */; }; DC0000000000002000000203 /* NotifyAboutCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000203 /* NotifyAboutCard.swift */; }; DC0000000000002000000701 /* ApprovalArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000701 /* ApprovalArbiter.swift */; }; - DC0000000000002000000800 /* SleepStateNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000800 /* SleepStateNotifier.swift */; }; DC0000000000002000000F01 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000F01 /* GlassCard.swift */; }; DC0000000000002000000F02 /* GlassButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000F02 /* GlassButtonStyle.swift */; }; DC0000000000002000000F03 /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0000000000001000000F03 /* AppRouter.swift */; }; @@ -138,7 +137,6 @@ DC0000000000001000000202 /* AgentNotificationCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationCatalog.swift; sourceTree = ""; }; DC0000000000001000000203 /* NotifyAboutCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyAboutCard.swift; sourceTree = ""; }; DC0000000000001000000701 /* ApprovalArbiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalArbiter.swift; sourceTree = ""; }; - DC0000000000001000000800 /* SleepStateNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStateNotifier.swift; sourceTree = ""; }; DC0000000000001000000F01 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; DC0000000000001000000F02 /* GlassButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButtonStyle.swift; sourceTree = ""; }; DC0000000000001000000F03 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = ""; }; @@ -233,7 +231,6 @@ DC0000000000001000000025 /* HookSocketListener.swift */, DC0000000000001000000032 /* AgentInstallerV2.swift */, DC0000000000001000000027 /* NotificationDispatcher.swift */, - DC0000000000001000000800 /* SleepStateNotifier.swift */, DC0000000000001000000028 /* AgentTrackingManager.swift */, DC0000000000001000000039 /* ConfigureAgentsWindowV2.swift */, DC0000000000001000000030 /* TrackAgentsPopover.swift */, @@ -414,7 +411,6 @@ DC0000000000002000000025 /* HookSocketListener.swift in Sources */, DC0000000000002000000032 /* AgentInstallerV2.swift in Sources */, DC0000000000002000000027 /* NotificationDispatcher.swift in Sources */, - DC0000000000002000000800 /* SleepStateNotifier.swift in Sources */, DC0000000000002000000028 /* AgentTrackingManager.swift in Sources */, DC0000000000002000000039 /* ConfigureAgentsWindowV2.swift in Sources */, DC0000000000002000000030 /* TrackAgentsPopover.swift in Sources */, @@ -586,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2702; + CURRENT_PROJECT_VERSION = 2703; DEVELOPMENT_TEAM = A9P2388PHM; ENABLE_HARDENED_RUNTIME = YES; GCC_TREAT_WARNINGS_AS_ERRORS = YES; @@ -596,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.7.2; + MARKETING_VERSION = 2.7.3; PRODUCT_BUNDLE_IDENTIFIER = com.doomcoder.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -614,7 +610,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2702; + CURRENT_PROJECT_VERSION = 2703; DEVELOPMENT_TEAM = A9P2388PHM; ENABLE_HARDENED_RUNTIME = YES; GCC_TREAT_WARNINGS_AS_ERRORS = YES; @@ -624,7 +620,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.7.2; + MARKETING_VERSION = 2.7.3; PRODUCT_BUNDLE_IDENTIFIER = com.doomcoder.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/DoomCoder/AgentTracking/AgentNotificationCatalog.swift b/DoomCoder/AgentTracking/AgentNotificationCatalog.swift index 22fc9b3..4d6dcb2 100644 --- a/DoomCoder/AgentTracking/AgentNotificationCatalog.swift +++ b/DoomCoder/AgentTracking/AgentNotificationCatalog.swift @@ -125,12 +125,15 @@ enum AgentNotificationCatalog { case .cursor: return [.completed, .failed, .waitingApproval, .waitingInput, .sessionStart, .toolCalls, .subagentActivity, .fileEdits, .contextCompaction, .agentThinking, .housekeeping, .userPromptSent] + // `.housekeeping` is listed for every agent with a session-close event + // (see `isSessionClose`) so the toggle that re-enables close alerts is + // reachable in the editor. case .vscode: return [.completed, .failed, .waitingApproval, .sessionStart, .toolCalls, .subagentActivity, - .contextCompaction, .userPromptSent] + .contextCompaction, .housekeeping, .userPromptSent] case .copilotCLI: return [.completed, .failed, .waitingApproval, .sessionStart, .toolCalls, .subagentActivity, - .contextCompaction, .userPromptSent] + .contextCompaction, .housekeeping, .userPromptSent] case .windsurf: // Windsurf emits no SessionStart, no error phase, no subagent events. return [.completed, .waitingApproval, .waitingInput, .toolCalls, @@ -276,6 +279,22 @@ enum AgentNotificationCatalog { } } + /// True when a `.sessionEnd` event means "the user closed the session" + /// rather than "the agent finished its turn". Both normalize to + /// `.sessionEnd` on purpose so the session state machine still finalizes + /// (`hasEnded`, auto-revert, session history), but only the turn-end + /// variant is a "Task completed" alert. Closing an agent almost always + /// follows a real turn-end event, so notifying on both double-fires. + /// Routed to the default-off `housekeeping` switch by + /// `AgentNotificationPrefs.shouldNotify`. + static func isSessionClose(for agent: TrackedAgent, rawEvent: String) -> Bool { + switch agent { + case .claude, .vscode: return rawEvent == "SessionEnd" + case .cursor, .copilotCLI: return rawEvent == "sessionEnd" + case .windsurf, .codexCLI, .opencode: return false + } + } + /// Classifies a `.permissionNeeded` event into an approval-kind id for the /// agent. Mirrors the per-agent logic in AgentEventNormalizer.swift. static func approvalKindID(for agent: TrackedAgent, rawEvent: String, toolName: String?) -> String { diff --git a/DoomCoder/AgentTracking/AgentNotificationPrefs.swift b/DoomCoder/AgentTracking/AgentNotificationPrefs.swift index 1ead3ec..a2d67ea 100644 --- a/DoomCoder/AgentTracking/AgentNotificationPrefs.swift +++ b/DoomCoder/AgentTracking/AgentNotificationPrefs.swift @@ -139,6 +139,13 @@ struct AgentNotificationPrefs: Codable, Equatable, Sendable { func shouldNotify(_ ev: NormalizedHookEvent) -> Bool { switch ev.phase { case .sessionEnd: + // Closing an agent is not "task completed" — it fires right after a + // real turn-end event and double-notifies. Gate it on the + // default-off housekeeping switch instead. The phase stays + // `.sessionEnd` so session finalization is unaffected. + if AgentNotificationCatalog.isSessionClose(for: ev.agent, rawEvent: ev.rawEvent) { + return housekeeping + } return completed case .error, .toolError: return failed diff --git a/DoomCoder/AgentTracking/SleepStateNotifier.swift b/DoomCoder/AgentTracking/SleepStateNotifier.swift deleted file mode 100644 index 421713d..0000000 --- a/DoomCoder/AgentTracking/SleepStateNotifier.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import UserNotifications -import DoomCoderCore - -/// Posts system-level sleep control notifications through both the local -/// macOS notification center and the CloudKit pipeline so iOS also receives -/// them as push notifications. -/// -/// These are not agent-hook events, so they bypass NotificationDispatcher -/// and write NotificationLogRecord directly to CloudKitPusher. -@MainActor -final class SleepStateNotifier { - static let shared = SleepStateNotifier() - private init() {} - - // MARK: - Public notification triggers - - /// Why the Mac is now being held awake. Drives the body copy so the user - /// can tell at a glance whether it's the agent, their own activity, or a - /// snooze override keeping the Mac awake. - enum TakeReason: Sendable { - /// One or more tracked agents is actively working. - case agents([String]) - /// No agents working, but the user has been at the keyboard / mouse - /// within the silence window. Mac stays awake while you're active. - case userActive - /// A snooze override is in effect. Mac stays awake regardless of - /// agent or user activity until the timer ends. - case snoozed - - /// Localized body copy. Kept short — the system notification can - /// only comfortably fit ~80 characters. - var body: String { - switch self { - case .agents(let names): - if names.isEmpty { return "Doom Coder is holding sleep for your agents." } - if names.count == 1 { return "\(names[0]) is working." } - return "\(names.prefix(2).joined(separator: " & ")) are working." - case .userActive: - return "You're active — Doom Coder is holding sleep." - case .snoozed: - return "Snooze active — Doom Coder is holding sleep." - } - } - } - - func notifyTookControl(reason: TakeReason) { - // v2.6: removed the 20s debounce. The acquire guard in - // SleepManager.acquireAssertion() already prevents duplicate fires - // within a single active session (acquire only runs on the - // inactive→active transition). The debounce was previously - // suppressing legitimate feedback when the user toggled modes - // rapidly (e.g. Auto → Off → Auto) and they expected a notification - // each time. The 10-min silence window in evaluateAuto is what - // actually prevents oscillation. - let title = "Keeping Mac Awake" - post(title: title, body: reason.body, identifier: "sleep.took.\(UUID().uuidString)") - } - - /// Backwards-compatible overload: derives the reason from the agent - /// names. New code should pass an explicit `TakeReason`. - func notifyTookControl(agentNames: [String]) { - let reason: TakeReason = agentNames.isEmpty ? .userActive : .agents(agentNames) - notifyTookControl(reason: reason) - } - - func notifyReleasedControl() { - post(title: "Sleep Control Returned", - body: "All agents finished. Your Mac can sleep normally.", - identifier: "sleep.released") - } - - // MARK: - Internal dispatch - - private func post(title: String, body: String, identifier: String) { - postLocal(title: title, body: body, identifier: identifier) - postCloudKit(title: title, body: body, identifier: identifier) - } - - private func postLocal(title: String, body: String, identifier: String) { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - let req = UNNotificationRequest(identifier: identifier, - content: content, - trigger: nil) - Task { try? await UNUserNotificationCenter.current().add(req) } - } - - private func postCloudKit(title: String, body: String, identifier: String) { - let pusher = CloudKitPusher.shared - let rec = NotificationLogRecord( - sessionKey: "system", - macId: pusher.macId, - macName: pusher.macName, - agent: "doomcoder", - phase: identifier, - rawEvent: identifier, - title: title, - body: body, - channel: "iOS", - success: true, - ts: Date() - ) - pusher.publishNotificationLog(rec) - } -} diff --git a/DoomCoder/SleepManager.swift b/DoomCoder/SleepManager.swift index a395cdb..02db9ce 100644 --- a/DoomCoder/SleepManager.swift +++ b/DoomCoder/SleepManager.swift @@ -390,19 +390,6 @@ final class SleepManager { resetSessionTimer() if mode == .screenOff { startScreenOff() } notifyStateChanged() - // Notify user when Auto mode takes sleep control. The copy reflects - // WHY the Mac is held (agents / user activity / snooze) so the user - // can tell at a glance. - if keepAwakeMode == .auto { - let reason: SleepStateNotifier.TakeReason - if isSnoozed { - reason = .snoozed - } else { - let names = autoStatusLines.map(\.agentDisplayName) - reason = names.isEmpty ? .userActive : .agents(names) - } - SleepStateNotifier.shared.notifyTookControl(reason: reason) - } } private func releaseAssertion() { @@ -600,10 +587,7 @@ final class SleepManager { if shouldHoldAuto { if !isActive { acquireAssertion() } } else { - if isActive { - releaseAssertion() - SleepStateNotifier.shared.notifyReleasedControl() - } + if isActive { releaseAssertion() } } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c7776f3..7366f4f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -90,6 +90,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.7.3] - 2026-08-12 + +macOS only. The iOS companion is unchanged and stays on its current +App Store version. + +### Fixed — notification spam + +- **No more "Keeping Mac Awake" / "Sleep Control Returned" banners.** + Both fired on every `isActive` transition in Auto mode, and the hold + condition is an OR of three volatile signals with no hysteresis and no + debounce. Every walk-away-and-come-back cycle cost two notifications, + and toggling Auto off and back on re-fired the first one. They were + also the only notifications in the app that bypassed the preference + gate entirely, so there was no way to switch them off. Sleep state is + already live in the menu bar icon, the panel, and the iOS Dashboard via + `MacStatus.sleepActive`, so the banners were repeating what the UI + already showed. `SleepStateNotifier` is removed. +- **Closing an agent no longer fires "Task completed".** A session-close + event (`SessionEnd` / `sessionEnd`) normalizes to the same + `.sessionEnd` phase as a turn-end event (`Stop` / `agentStop`), so + quitting Claude Code, Cursor, VS Code Copilot, or Copilot CLI produced + a duplicate "done" alert seconds after the real one. Close events are + now gated on the default-off **Housekeeping** switch instead. The phase + is unchanged, so session finalization, auto-revert, and session history + all behave exactly as before, and the event is still recorded and + visible in Activity. Turn-end alerts are untouched. Flip Housekeeping + on in Configure > Agents to get close alerts back. +- **Housekeeping toggle now shown for VS Code Copilot and Copilot CLI**, + so the switch that re-enables close alerts is reachable for those two. + +--- + ## [2.7.2] - 2026-06-09 ### Added