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