Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25030ee
feat: suppress ignored Claude notifications
austinywang May 12, 2026
17f1477
Merge remote-tracking branch 'origin/main' into issue-3602-suppress-i…
austinywang May 12, 2026
636c344
fix: tighten Claude notification type filtering
austinywang May 12, 2026
bee9922
refactor: share Claude notification type normalization
austinywang May 12, 2026
0cf31e7
Merge remote-tracking branch 'origin/main' into issue-3602-suppress-i…
austinywang May 12, 2026
5adc3d5
fix: extract ignored Claude types from raw hook input
austinywang May 12, 2026
0a9029e
refactor: share Claude notification filter keys
austinywang May 12, 2026
d9091ba
Merge remote-tracking branch 'origin/main' into issue-3602-suppress-i…
austinywang May 13, 2026
eb366d3
merge: resolve conflicts with main
austinywang May 14, 2026
48ea5c2
Merge remote-tracking branch 'origin/main' into issue-3602-suppress-i…
austinywang May 17, 2026
a22d3b6
fix: sanitize suppressed notification telemetry
austinywang May 18, 2026
1a010d2
fix: parse fallback Claude notification types
austinywang May 18, 2026
183a71a
fix: scope Claude notification type extraction
austinywang May 18, 2026
9a613da
Merge remote-tracking branch 'origin/main' into issue-3602-suppress-i…
austinywang May 18, 2026
dcddc10
merge: sync with origin main
austinywang May 19, 2026
57574ad
fix: read live Claude notification settings
austinywang May 19, 2026
23613f8
fix: skip invalid Claude notification configs
austinywang May 19, 2026
0d442bc
fix: honor live Claude notification config precedence
austinywang May 19, 2026
270a7c7
fix: reject invalid Claude notification config values
austinywang May 19, 2026
76ee9f0
fix: treat malformed notifications config as invalid
austinywang May 19, 2026
4c7ba93
refactor: isolate Claude notification domain
austinywang May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ private struct ClaudeHookParsedInput {
let rawObject: [String: Any]?
let object: [String: Any]?
let rawFallback: String?
let rawFallbackNotificationTypeValues: [String]
let sessionId: String?
let turnId: String?
let cwd: String?
Expand Down Expand Up @@ -16696,6 +16697,16 @@ struct CMUXCLI {

case "notification", "notify":
telemetry.breadcrumb("claude-hook.notification")
let notificationTypes = Self.claudeNotificationTypes(parsedInput: parsedInput)
let suppressedNotificationTypes = Self.suppressedClaudeNotificationTypes(for: notificationTypes)
if !suppressedNotificationTypes.isEmpty {
telemetry.breadcrumb(
"claude-hook.notification.suppressed",
data: ["types": suppressedNotificationTypes.sorted().joined(separator: ",")]
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
print("OK")
return
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
}
var summary = summarizeClaudeHookNotification(parsedInput: parsedInput)

let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
Expand Down Expand Up @@ -17312,7 +17323,7 @@ struct CMUXCLI {
guard !trimmed.isEmpty,
let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let object = json as? [String: Any] else {
let object = Self.claudeHookObject(fromJSONValue: json) else {
let fallback = trimmed.isEmpty ? nil : truncate(
normalizedSingleLine(redactClaudeSensitiveSpans(trimmed)),
maxLength: 180
Expand All @@ -17321,6 +17332,7 @@ struct CMUXCLI {
rawObject: nil,
object: nil,
rawFallback: fallback,
rawFallbackNotificationTypeValues: Self.claudeNotificationTypeValues(inRawFallback: trimmed),
sessionId: nil,
turnId: nil,
cwd: nil,
Expand All @@ -17337,13 +17349,27 @@ struct CMUXCLI {
rawObject: object,
object: compactObject,
rawFallback: nil,
rawFallbackNotificationTypeValues: [],
sessionId: sessionId,
turnId: turnId,
cwd: cwd,
transcriptPath: transcriptPath
)
}

private static func claudeHookObject(fromJSONValue value: Any) -> [String: Any]? {
if let object = value as? [String: Any] {
return object
}
guard let string = value as? String,
let data = string.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8),
let nested = try? JSONSerialization.jsonObject(with: data, options: []),
let object = nested as? [String: Any] else {
return nil
}
return object
}

private func compactClaudeHookObject(_ object: [String: Any]) -> [String: Any] {
var compact: [String: Any] = [:]

Expand Down Expand Up @@ -18722,6 +18748,109 @@ struct CMUXCLI {
return ("Attention", "Claude needs your attention")
}

private static let claudeIgnoredNotificationTypesDefaultsKey =
ClaudeNotificationTypeNormalization.ignoredTypesDefaultsKey
private static let claudeIgnoredNotificationTypesEnvKey =
ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey

private static func suppressedClaudeNotificationTypes(for types: Set<String>) -> Set<String> {
let ignoredTypes = ignoredClaudeNotificationTypes()
guard !ignoredTypes.isEmpty, !types.isEmpty else {
return []
}
return ignoredTypes.intersection(types)
}

private static func ignoredClaudeNotificationTypes() -> Set<String> {
if let env = ProcessInfo.processInfo.environment[claudeIgnoredNotificationTypesEnvKey] {
return normalizedClaudeNotificationTypes(
env.components(separatedBy: CharacterSet(charactersIn: ", \n\t"))
)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if let stored = UserDefaults.standard.array(forKey: claudeIgnoredNotificationTypesDefaultsKey) as? [String] {
return normalizedClaudeNotificationTypes(stored)
}
if let stored = UserDefaults.standard.string(forKey: claudeIgnoredNotificationTypesDefaultsKey) {
return normalizedClaudeNotificationTypes(
stored.components(separatedBy: CharacterSet(charactersIn: ", \n\t"))
)
}
return []
}

private static func claudeNotificationTypes(parsedInput: ClaudeHookParsedInput) -> Set<String> {
var rawValues: [String] = []
if let object = parsedInput.rawObject ?? parsedInput.object {
rawValues.append(contentsOf: claudeNotificationTypeValues(inJSONValue: object))
}
rawValues.append(contentsOf: parsedInput.rawFallbackNotificationTypeValues)
return normalizedClaudeNotificationTypes(rawValues)
}

private enum ClaudeNotificationTypeExtractionScope {
case root
case notificationPayload
}

private static func claudeNotificationTypeValues(
inRawFallback fallback: String,
scope: ClaudeNotificationTypeExtractionScope = .root
) -> [String] {
let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) else {
return []
}
return claudeNotificationTypeValues(inJSONValue: json, scope: scope)
}

private static func claudeNotificationTypeValues(
inJSONValue value: Any,
scope: ClaudeNotificationTypeExtractionScope = .root
) -> [String] {
if let object = value as? [String: Any] {
var values = claudeNotificationTypeValues(in: object, scope: scope)
for key in ["notification", "data"] {
if let nested = object[key] {
values.append(contentsOf: claudeNotificationTypeValues(
inJSONValue: nested,
scope: .notificationPayload
))
}
}
return values
}
if let values = value as? [Any] {
return values.flatMap { claudeNotificationTypeValues(inJSONValue: $0, scope: scope) }
}
if let string = value as? String {
return claudeNotificationTypeValues(inRawFallback: string, scope: scope)
}
return []
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

private static func claudeNotificationTypeValues(
in object: [String: Any],
scope: ClaudeNotificationTypeExtractionScope
) -> [String] {
let keys: [String]
switch scope {
case .root:
keys = ["notification_type", "matcher", "reason"]
case .notificationPayload:
keys = ["notification_type", "matcher", "reason", "type", "kind"]
}
return keys.compactMap { key in
object[key] as? String
}
}

private static func normalizedClaudeNotificationTypes(_ values: [String]) -> Set<String> {
ClaudeNotificationTypeNormalization.normalizedSet(values)
}

private func firstString(in object: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = object[key] else { continue }
Expand Down
34 changes: 34 additions & 0 deletions Sources/ClaudeNotificationTypeNormalization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

enum ClaudeNotificationTypeNormalization {
static let ignoredTypesDefaultsKey = "claudeCodeIgnoredNotificationTypes"
static let ignoredTypesEnvironmentKey = "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"
static let defaultIgnoredTypes: [String] = []

static func normalized(_ raw: String) -> String? {
let collapsedWhitespace = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: "_")
let normalized = collapsedWhitespace
.replacingOccurrences(of: "-", with: "_")
.lowercased()
return normalized.isEmpty ? nil : normalized
}

static func normalizedSet(_ values: [String]) -> Set<String> {
Set(values.compactMap(normalized))
}

static func normalizedUniqueList(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.compactMap { raw in
guard let normalized = normalized(raw),
seen.insert(normalized).inserted else {
return nil
}
return normalized
}
}
}
1 change: 1 addition & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ extension CmuxSettingsFileStore {
"notifications.sound",
"notifications.customSoundFilePath",
"notifications.command",
"notifications.ignoredClaudeNotificationTypes",
"notifications.hooks",
"notifications.hooksMode",
"sidebar.hideAllDetails",
Expand Down
4 changes: 4 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5515,6 +5515,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
if !claudeHooksEnabled {
setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1")
}
setManagedEnvironmentValue(
ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey,
ClaudeCodeIntegrationSettings.ignoredNotificationTypes().joined(separator: ",")
)
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() {
setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/KeyboardShortcutSettingsFileStore+Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ extension CmuxSettingsFileStore {
"sound": NotificationSoundSettings.defaultValue,
"customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath,
"command": NotificationSoundSettings.defaultCustomCommand,
"ignoredClaudeNotificationTypes": ClaudeCodeIntegrationSettings.defaultIgnoredNotificationTypes,
"hooksMode": "append",
"hooks": [],
],
Expand Down
7 changes: 7 additions & 0 deletions Sources/KeyboardShortcutSettingsFileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,13 @@ final class CmuxSettingsFileStore {
if let raw = jsonString(section["command"]) {
snapshot.managedUserDefaults[NotificationSoundSettings.customCommandKey] = .string(raw)
}
if let values = jsonStringArray(section["ignoredClaudeNotificationTypes"]) {
snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.ignoredNotificationTypesKey] = .stringArray(
ClaudeCodeIntegrationSettings.normalizedIgnoredNotificationTypes(values)
)
} else if section.keys.contains("ignoredClaudeNotificationTypes") {
logInvalid("notifications.ignoredClaudeNotificationTypes", sourcePath: sourcePath)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

App merge ignores hook precedence

Medium Severity

When a valid primary cmux.json exists but omits notifications.ignoredClaudeNotificationTypes, the Claude hook treats the ignore list as empty and still renders matching notifications. The settings file store can still merge ignored types from legacy or App Support configs into UserDefaults and terminal env, so suppression looks enabled while hooks do not suppress.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4c7ba93. Configure here.

}

private func parseTerminalSection(
Expand Down
13 changes: 13 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4666,6 +4666,8 @@ enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
static let customClaudePathKey = "claudeCodeCustomClaudePath"
static let ignoredNotificationTypesKey = ClaudeNotificationTypeNormalization.ignoredTypesDefaultsKey
static let defaultIgnoredNotificationTypes = ClaudeNotificationTypeNormalization.defaultIgnoredTypes

static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: hooksEnabledKey) == nil {
Expand All @@ -4679,6 +4681,17 @@ enum ClaudeCodeIntegrationSettings {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}

static func ignoredNotificationTypes(defaults: UserDefaults = .standard) -> [String] {
guard let stored = defaults.array(forKey: ignoredNotificationTypesKey) as? [String] else {
return defaultIgnoredNotificationTypes
}
return normalizedIgnoredNotificationTypes(stored)
}

static func normalizedIgnoredNotificationTypes(_ values: [String]) -> [String] {
ClaudeNotificationTypeNormalization.normalizedUniqueList(values)
}
}

enum CursorIntegrationSettings {
Expand Down
6 changes: 6 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; };
C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; };
C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; };
3C0EFC65E21A473783BA8A71 /* ClaudeNotificationTypeNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */; };
49F3FB1408664E65A47CF04F /* ClaudeNotificationTypeNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */; };
C0DEF0B30000000000000001 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B30000000000000002 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift */; };
E3337001E3337001E3337001 /* KeyboardShortcutSettingsFileStoreStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3337002E3337002E3337002 /* KeyboardShortcutSettingsFileStoreStartupTests.swift */; };
C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */; };
Expand Down Expand Up @@ -893,6 +895,7 @@
C34670030000000000000002 /* KeyboardShortcutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContext.swift; sourceTree = "<group>"; };
C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarPerformanceSupportTests.swift; sourceTree = "<group>"; };
C0DEF0B10000000000000002 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; };
D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationTypeNormalization.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeHeightUITests.swift; sourceTree = "<group>"; };
B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1254,6 +1257,7 @@
A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */,
A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */,
C0DEF0B10000000000000002 /* JSONCParser.swift */,
D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */,
A50012F4 /* KeyboardLayout.swift */,
A5001013 /* TabManager.swift */,
C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */,
Expand Down Expand Up @@ -1982,6 +1986,7 @@
A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */,
A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */,
C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */,
3C0EFC65E21A473783BA8A71 /* ClaudeNotificationTypeNormalization.swift in Sources */,
D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */,
A50012F5 /* KeyboardLayout.swift in Sources */,
A5001003 /* TabManager.swift in Sources */,
Expand Down Expand Up @@ -2218,6 +2223,7 @@
B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */,
B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */,
C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */,
49F3FB1408664E65A47CF04F /* ClaudeNotificationTypeNormalization.swift in Sources */,
D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */,
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */,
);
Expand Down
55 changes: 55 additions & 0 deletions cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,61 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase {
XCTAssertEqual(dockTileNotificationCount, 0)
}

func testSettingsFileStoreAppliesIgnoredClaudeNotificationTypes() throws {
let defaults = UserDefaults.standard
let key = ClaudeCodeIntegrationSettings.ignoredNotificationTypesKey
let previousValue = defaults.object(forKey: key)
let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey)
let previousImportedDefaults = defaults.data(forKey: importedManagedDefaultsKey)
defer {
if let previousValue {
defaults.set(previousValue, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
if let previousBackups {
defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey)
} else {
defaults.removeObject(forKey: settingsFileBackupsDefaultsKey)
}
if let previousImportedDefaults {
defaults.set(previousImportedDefaults, forKey: importedManagedDefaultsKey)
} else {
defaults.removeObject(forKey: importedManagedDefaultsKey)
}
}

defaults.removeObject(forKey: key)
defaults.removeObject(forKey: settingsFileBackupsDefaultsKey)
defaults.removeObject(forKey: importedManagedDefaultsKey)

let directoryURL = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directoryURL) }

let settingsURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false)
try writeSettingsFile(
"""
{
"notifications": {
"ignoredClaudeNotificationTypes": ["idle_prompt", "idle-prompt", " permission_prompt ", ""]
}
}
""",
to: settingsURL
)

_ = KeyboardShortcutSettingsFileStore(
primaryPath: settingsURL.path,
fallbackPath: nil,
startWatching: false
)

XCTAssertEqual(
ClaudeCodeIntegrationSettings.ignoredNotificationTypes(defaults: defaults),
["idle_prompt", "permission_prompt"]
)
}

func testSidebarMatchTerminalBackgroundUserDefaultSurvivesSettingsFileReapply() throws {
let defaults = UserDefaults.standard
let key = SidebarMatchTerminalBackgroundSettings.userDefaultsKey
Expand Down
Loading
Loading