From 25030eef3e3855f95ccf1b024eadfbafd969a737 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:50:07 -0700 Subject: [PATCH 01/14] feat: suppress ignored Claude notifications --- CLI/cmux.swift | 81 +++++++ Sources/CmuxSettingsJSONPathSupport.swift | 2 + Sources/GhosttyTerminalView.swift | 4 + ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 25 ++ Sources/cmuxApp.swift | 32 +++ ...hortcutSettingsFileStoreStartupTests.swift | 55 +++++ docs/notifications.md | 12 + ..._claude_hook_ignored_notification_types.py | 216 ++++++++++++++++++ 9 files changed, 428 insertions(+) create mode 100644 tests/test_claude_hook_ignored_notification_types.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fee11771fb25..0fbc7d297e02 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14327,6 +14327,15 @@ struct CMUXCLI { case "notification", "notify": telemetry.breadcrumb("claude-hook.notification") + let notificationTypes = Self.claudeNotificationTypes(parsedInput: parsedInput) + if Self.shouldSuppressClaudeNotification(types: notificationTypes) { + telemetry.breadcrumb( + "claude-hook.notification.suppressed", + data: ["types": notificationTypes.sorted().joined(separator: ",")] + ) + print("OK") + return + } var summary = summarizeClaudeHookNotification(parsedInput: parsedInput) let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } @@ -15960,6 +15969,78 @@ struct CMUXCLI { return ("Attention", "Claude needs your attention") } + private static let claudeIgnoredNotificationTypesDefaultsKey = "claudeCodeIgnoredNotificationTypes" + private static let claudeIgnoredNotificationTypesEnvKey = "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES" + + private static func shouldSuppressClaudeNotification(types: Set) -> Bool { + let ignoredTypes = ignoredClaudeNotificationTypes() + guard !ignoredTypes.isEmpty, !types.isEmpty else { + return false + } + return !ignoredTypes.isDisjoint(with: types) + } + + private static func ignoredClaudeNotificationTypes() -> Set { + let env = ProcessInfo.processInfo.environment[claudeIgnoredNotificationTypesEnvKey] ?? "" + let envTypes = normalizedClaudeNotificationTypes( + env.components(separatedBy: CharacterSet(charactersIn: ", \n\t")) + ) + if !envTypes.isEmpty { + return envTypes + } + + 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 { + var rawValues: [String] = [] + if let object = parsedInput.object { + rawValues.append(contentsOf: claudeNotificationTypeValues(in: object)) + for key in ["notification", "data"] { + if let nested = object[key] as? [String: Any] { + rawValues.append(contentsOf: claudeNotificationTypeValues(in: nested)) + } + } + } + if let fallback = parsedInput.rawFallback { + rawValues.append(fallback) + } + return normalizedClaudeNotificationTypes(rawValues) + } + + private static func claudeNotificationTypeValues(in object: [String: Any]) -> [String] { + [ + "event", "event_name", "hook_event_name", "type", "kind", + "notification_type", "matcher", "reason" + ].compactMap { key in + object[key] as? String + } + } + + private static func normalizedClaudeNotificationTypes(_ values: [String]) -> Set { + Set(values.compactMap { normalizedClaudeNotificationType($0) }) + } + + private static func normalizedClaudeNotificationType(_ 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 + } + private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index f40f1fd297db..0f9cd5afc9fa 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -45,6 +45,8 @@ extension CmuxSettingsFileStore { "notifications.sound", "notifications.customSoundFilePath", "notifications.command", + "notifications.ignoredClaudeNotificationTypes", + "notifications.suppressClaudeIdlePrompt", "sidebar.hideAllDetails", "sidebar.branchLayout", "sidebar.showNotificationMessage", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1cc167b57e1d..10c1175313d0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5173,6 +5173,10 @@ final class TerminalSurface: Identifiable, ObservableObject { if !claudeHooksEnabled { setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } + setManagedEnvironmentValue( + "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES", + ClaudeCodeIntegrationSettings.ignoredNotificationTypes().joined(separator: ",") + ) if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() { setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath) } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 066656c88316..35859ea2e84e 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -90,6 +90,7 @@ extension CmuxSettingsFileStore { "sound": NotificationSoundSettings.defaultValue, "customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath, "command": NotificationSoundSettings.defaultCustomCommand, + "ignoredClaudeNotificationTypes": ClaudeCodeIntegrationSettings.defaultIgnoredNotificationTypes, ], ], [ diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 0ed9252c47bc..aae090df7dd8 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -444,6 +444,31 @@ 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) + } + if let value = jsonBool(section["suppressClaudeIdlePrompt"]) { + let key = ClaudeCodeIntegrationSettings.ignoredNotificationTypesKey + let existing: [String] + if case .stringArray(let values)? = snapshot.managedUserDefaults[key] { + existing = values + } else { + existing = ClaudeCodeIntegrationSettings.defaultIgnoredNotificationTypes + } + let idlePromptType = "idle_prompt" + let updated = value + ? existing + [idlePromptType] + : existing.filter { $0 != idlePromptType } + snapshot.managedUserDefaults[key] = .stringArray( + ClaudeCodeIntegrationSettings.normalizedIgnoredNotificationTypes(updated) + ) + } else if section.keys.contains("suppressClaudeIdlePrompt") { + logInvalid("notifications.suppressClaudeIdlePrompt", sourcePath: sourcePath) + } } private func parseTerminalSection( diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index b7b8e94170cd..fcff812f96f7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4651,6 +4651,8 @@ enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true static let customClaudePathKey = "claudeCodeCustomClaudePath" + static let ignoredNotificationTypesKey = "claudeCodeIgnoredNotificationTypes" + static let defaultIgnoredNotificationTypes: [String] = [] static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: hooksEnabledKey) == nil { @@ -4664,6 +4666,36 @@ 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] { + var seen = Set() + return values.compactMap { raw in + guard let normalized = normalizedNotificationType(raw), + seen.insert(normalized).inserted else { + return nil + } + return normalized + } + } + + static func normalizedNotificationType(_ 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 + } } enum CursorIntegrationSettings { diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 6988914c96c1..d503e82bf5b3 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -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 diff --git a/docs/notifications.md b/docs/notifications.md index a25678a73f69..021cb967b704 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -60,6 +60,18 @@ cmux notify --title "Done" --tab 0 --panel 1 See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code) for hook configuration. +To keep Claude Code integration on but suppress recurring idle prompt notifications, add this to `~/.config/cmux/cmux.json`: + +```json +{ + "notifications": { + "ignoredClaudeNotificationTypes": ["idle_prompt"] + } +} +``` + +This only skips Claude Code `Notification` hook rendering for matching notification types. Stop notifications and per-workspace running/stopped status still work. + ### GitHub Copilot CLI Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) that run shell commands at key lifecycle events. Add to `~/.copilot/config.json`: diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py new file mode 100644 index 000000000000..ad96c037ddba --- /dev/null +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Regression: Claude ignored notification types should not render notifications.""" + +from __future__ import annotations + +import glob +import json +import os +import shutil +import socket +import subprocess +import tempfile +import threading +import time +import uuid + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit: + if os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + raise RuntimeError(f"Configured cmux CLI is not executable: {explicit}") + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [path for path in candidates if os.path.exists(path) and os.access(path, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +class CapturingSocketServer: + def __init__(self) -> None: + self.commands: list[str] = [] + self.ready = threading.Event() + self.stop = threading.Event() + self.error: Exception | None = None + self.root = tempfile.TemporaryDirectory(prefix="cmux-claude-ignored-notifications-") + self.socket_path = os.path.join(self.root.name, "cmux.sock") + self.thread = threading.Thread(target=self._run, daemon=True) + self.server: socket.socket | None = None + + def __enter__(self) -> "CapturingSocketServer": + self.thread.start() + if not self.ready.wait(timeout=2.0): + raise RuntimeError("socket server did not become ready") + if self.error is not None: + raise self.error + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self.stop.set() + if self.server is not None: + self.server.close() + self.thread.join(timeout=2.0) + self.root.cleanup() + + def _run(self) -> None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + self.server = server + server.bind(self.socket_path) + server.listen(4) + server.settimeout(0.1) + self.ready.set() + while not self.stop.is_set(): + try: + conn, _ = server.accept() + except socket.timeout: + continue + except OSError: + return + threading.Thread(target=self._handle, args=(conn,), daemon=True).start() + except Exception as exc: + self.error = exc + self.ready.set() + + def _handle(self, conn: socket.socket) -> None: + with conn: + conn.settimeout(0.1) + buffer = b"" + idle_deadline = time.time() + 6.0 + while not self.stop.is_set() and time.time() < idle_deadline: + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + break + idle_deadline = time.time() + 2.0 + buffer += chunk + while b"\n" in buffer: + raw_line, buffer = buffer.split(b"\n", 1) + if not raw_line: + continue + line = raw_line.decode("utf-8", errors="replace") + self.commands.append(line) + conn.sendall((self._response_for(line) + "\n").encode("utf-8")) + + def _response_for(self, line: str) -> str: + if line.startswith("{"): + try: + request = json.loads(line) + return json.dumps({"id": request.get("id"), "ok": True, "result": {}}) + except json.JSONDecodeError: + pass + return "OK" + + +def run_notification_hook( + cli_path: str, + server: CapturingSocketServer, + env: dict[str, str], + payload: dict, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [cli_path, "--socket", server.socket_path, "claude-hook", "notification"], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + timeout=8, + check=False, + ) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + workspace_id = str(uuid.uuid4()).upper() + surface_id = str(uuid.uuid4()).upper() + + with CapturingSocketServer() as server: + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = server.socket_path + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_CLAUDE_HOOK_STATE_PATH"] = os.path.join(server.root.name, "state.json") + env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "idle_prompt" + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + idle = run_notification_hook( + cli_path, + server, + env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Claude is waiting for your input", + }, + ) + if idle.returncode != 0 or idle.stdout.strip() != "OK": + print("FAIL: ignored idle prompt hook did not complete cleanly") + print(f"stdout={idle.stdout!r}") + print(f"stderr={idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + rendered_commands = [ + line for line in server.commands + if line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + ] + if rendered_commands: + print("FAIL: idle_prompt rendered despite ignored notification type") + print(f"commands={server.commands!r}") + return 1 + + before_permission_count = len(server.commands) + permission = run_notification_hook( + cli_path, + server, + env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "message": "Approve shell command?", + }, + ) + if permission.returncode != 0: + print("FAIL: non-ignored permission notification hook failed") + print(f"stdout={permission.stdout!r}") + print(f"stderr={permission.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + new_commands = server.commands[before_permission_count:] + if not any(line.startswith("set_status claude_code Needs input ") for line in new_commands): + print("FAIL: non-ignored notification did not set Needs input status") + print(f"new_commands={new_commands!r}") + return 1 + if not any(line.startswith("notify_target_async ") for line in new_commands): + print("FAIL: non-ignored notification did not render notify_target_async") + print(f"new_commands={new_commands!r}") + return 1 + + print("PASS: Claude ignored notification types suppress only matching hook notifications") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 636c344f9e91b527a8860074004c6bcabb07372e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:57:37 -0700 Subject: [PATCH 02/14] fix: tighten Claude notification type filtering --- CLI/cmux.swift | 13 ++++----- Sources/CmuxSettingsJSONPathSupport.swift | 1 - .../KeyboardShortcutSettingsFileStore.swift | 18 ------------- ..._claude_hook_ignored_notification_types.py | 27 +++++++++++++++++++ 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0fbc7d297e02..c61c02deb2d8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15981,12 +15981,10 @@ struct CMUXCLI { } private static func ignoredClaudeNotificationTypes() -> Set { - let env = ProcessInfo.processInfo.environment[claudeIgnoredNotificationTypesEnvKey] ?? "" - let envTypes = normalizedClaudeNotificationTypes( - env.components(separatedBy: CharacterSet(charactersIn: ", \n\t")) - ) - if !envTypes.isEmpty { - return envTypes + if let env = ProcessInfo.processInfo.environment[claudeIgnoredNotificationTypesEnvKey] { + return normalizedClaudeNotificationTypes( + env.components(separatedBy: CharacterSet(charactersIn: ", \n\t")) + ) } if let stored = UserDefaults.standard.array(forKey: claudeIgnoredNotificationTypesDefaultsKey) as? [String] { @@ -16018,8 +16016,7 @@ struct CMUXCLI { private static func claudeNotificationTypeValues(in object: [String: Any]) -> [String] { [ - "event", "event_name", "hook_event_name", "type", "kind", - "notification_type", "matcher", "reason" + "notification_type", "matcher", "reason", "type", "kind" ].compactMap { key in object[key] as? String } diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 0f9cd5afc9fa..ac3673af6d81 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -46,7 +46,6 @@ extension CmuxSettingsFileStore { "notifications.customSoundFilePath", "notifications.command", "notifications.ignoredClaudeNotificationTypes", - "notifications.suppressClaudeIdlePrompt", "sidebar.hideAllDetails", "sidebar.branchLayout", "sidebar.showNotificationMessage", diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index aae090df7dd8..914db2b4fe24 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -451,24 +451,6 @@ final class CmuxSettingsFileStore { } else if section.keys.contains("ignoredClaudeNotificationTypes") { logInvalid("notifications.ignoredClaudeNotificationTypes", sourcePath: sourcePath) } - if let value = jsonBool(section["suppressClaudeIdlePrompt"]) { - let key = ClaudeCodeIntegrationSettings.ignoredNotificationTypesKey - let existing: [String] - if case .stringArray(let values)? = snapshot.managedUserDefaults[key] { - existing = values - } else { - existing = ClaudeCodeIntegrationSettings.defaultIgnoredNotificationTypes - } - let idlePromptType = "idle_prompt" - let updated = value - ? existing + [idlePromptType] - : existing.filter { $0 != idlePromptType } - snapshot.managedUserDefaults[key] = .stringArray( - ClaudeCodeIntegrationSettings.normalizedIgnoredNotificationTypes(updated) - ) - } else if section.keys.contains("suppressClaudeIdlePrompt") { - logInvalid("notifications.suppressClaudeIdlePrompt", sourcePath: sourcePath) - } } private func parseTerminalSection( diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index ad96c037ddba..3bdfd5529e3b 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -208,6 +208,33 @@ def main() -> int: print(f"new_commands={new_commands!r}") return 1 + hook_event_env = env.copy() + hook_event_env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "notification" + before_hook_event_count = len(server.commands) + hook_event = run_notification_hook( + cli_path, + server, + hook_event_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "message": "Approve another command?", + }, + ) + if hook_event.returncode != 0: + print("FAIL: hook_event_name-only ignored type case failed") + print(f"stdout={hook_event.stdout!r}") + print(f"stderr={hook_event.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + hook_event_commands = server.commands[before_hook_event_count:] + if not any(line.startswith("notify_target_async ") for line in hook_event_commands): + print("FAIL: hook_event_name Notification was treated as a notification type") + print(f"hook_event_commands={hook_event_commands!r}") + return 1 + print("PASS: Claude ignored notification types suppress only matching hook notifications") return 0 From bee9922da07f3a1f2b1fb0812ee2db4f20891ac5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:59:29 -0700 Subject: [PATCH 03/14] refactor: share Claude notification type normalization --- CLI/cmux.swift | 14 +-------- GhosttyTabs.xcodeproj/project.pbxproj | 6 ++++ .../ClaudeNotificationTypeNormalization.swift | 30 +++++++++++++++++++ Sources/cmuxApp.swift | 21 +------------ 4 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 Sources/ClaudeNotificationTypeNormalization.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c61c02deb2d8..8e4fd3b6c8ac 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16023,19 +16023,7 @@ struct CMUXCLI { } private static func normalizedClaudeNotificationTypes(_ values: [String]) -> Set { - Set(values.compactMap { normalizedClaudeNotificationType($0) }) - } - - private static func normalizedClaudeNotificationType(_ 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 + ClaudeNotificationTypeNormalization.normalizedSet(values) } private func firstString(in object: [String: Any], keys: [String]) -> String? { diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 897fb006a6db..ecf70340ed28 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -40,6 +40,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 */; }; @@ -804,6 +806,7 @@ C34670030000000000000002 /* KeyboardShortcutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContext.swift; sourceTree = ""; }; C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarPerformanceSupportTests.swift; sourceTree = ""; }; C0DEF0B10000000000000002 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; + D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationTypeNormalization.swift; sourceTree = ""; }; AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeHeightUITests.swift; sourceTree = ""; }; B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; @@ -1152,6 +1155,7 @@ A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */, C0DEF0B10000000000000002 /* JSONCParser.swift */, + D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */, @@ -1786,6 +1790,7 @@ A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */, C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */, + 3C0EFC65E21A473783BA8A71 /* ClaudeNotificationTypeNormalization.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */, @@ -1992,6 +1997,7 @@ B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */, B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, + 49F3FB1408664E65A47CF04F /* ClaudeNotificationTypeNormalization.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/ClaudeNotificationTypeNormalization.swift b/Sources/ClaudeNotificationTypeNormalization.swift new file mode 100644 index 000000000000..c2b63f5fad26 --- /dev/null +++ b/Sources/ClaudeNotificationTypeNormalization.swift @@ -0,0 +1,30 @@ +import Foundation + +enum ClaudeNotificationTypeNormalization { + 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 { + Set(values.compactMap(normalized)) + } + + static func normalizedUniqueList(_ values: [String]) -> [String] { + var seen = Set() + return values.compactMap { raw in + guard let normalized = normalized(raw), + seen.insert(normalized).inserted else { + return nil + } + return normalized + } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index fcff812f96f7..45a2d661197f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4675,26 +4675,7 @@ enum ClaudeCodeIntegrationSettings { } static func normalizedIgnoredNotificationTypes(_ values: [String]) -> [String] { - var seen = Set() - return values.compactMap { raw in - guard let normalized = normalizedNotificationType(raw), - seen.insert(normalized).inserted else { - return nil - } - return normalized - } - } - - static func normalizedNotificationType(_ 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 + ClaudeNotificationTypeNormalization.normalizedUniqueList(values) } } From 5adc3d51f1d010f7ac48e9f60c5c653e4bed522e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:13:11 -0700 Subject: [PATCH 04/14] fix: extract ignored Claude types from raw hook input --- CLI/cmux.swift | 2 +- ..._claude_hook_ignored_notification_types.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8edb49f242a6..4f3e81f63a2f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16340,7 +16340,7 @@ struct CMUXCLI { private static func claudeNotificationTypes(parsedInput: ClaudeHookParsedInput) -> Set { var rawValues: [String] = [] - if let object = parsedInput.object { + if let object = parsedInput.rawObject ?? parsedInput.object { rawValues.append(contentsOf: claudeNotificationTypeValues(in: object)) for key in ["notification", "data"] { if let nested = object[key] as? [String: Any] { diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 3bdfd5529e3b..ccf3a13370b2 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -179,6 +179,35 @@ def main() -> int: print(f"commands={server.commands!r}") return 1 + nested_idle = run_notification_hook( + cli_path, + server, + env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification": { + "notification_type": "idle_prompt", + "message": "Claude is still waiting for your input", + }, + }, + ) + if nested_idle.returncode != 0 or nested_idle.stdout.strip() != "OK": + print("FAIL: nested ignored idle prompt hook did not complete cleanly") + print(f"stdout={nested_idle.stdout!r}") + print(f"stderr={nested_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + nested_rendered_commands = [ + line for line in server.commands + if line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + ] + if nested_rendered_commands: + print("FAIL: nested idle_prompt rendered despite ignored notification type") + print(f"commands={server.commands!r}") + return 1 + before_permission_count = len(server.commands) permission = run_notification_hook( cli_path, From 0a9029ecc0f9b22a58d7adcc92bfcd0a5b8cd357 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:20:54 -0700 Subject: [PATCH 05/14] refactor: share Claude notification filter keys --- CLI/cmux.swift | 6 ++++-- Sources/ClaudeNotificationTypeNormalization.swift | 4 ++++ Sources/GhosttyTerminalView.swift | 2 +- Sources/cmuxApp.swift | 4 ++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4f3e81f63a2f..97345538cc18 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16309,8 +16309,10 @@ struct CMUXCLI { return ("Attention", "Claude needs your attention") } - private static let claudeIgnoredNotificationTypesDefaultsKey = "claudeCodeIgnoredNotificationTypes" - private static let claudeIgnoredNotificationTypesEnvKey = "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES" + private static let claudeIgnoredNotificationTypesDefaultsKey = + ClaudeNotificationTypeNormalization.ignoredTypesDefaultsKey + private static let claudeIgnoredNotificationTypesEnvKey = + ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey private static func shouldSuppressClaudeNotification(types: Set) -> Bool { let ignoredTypes = ignoredClaudeNotificationTypes() diff --git a/Sources/ClaudeNotificationTypeNormalization.swift b/Sources/ClaudeNotificationTypeNormalization.swift index c2b63f5fad26..bdd530bf0326 100644 --- a/Sources/ClaudeNotificationTypeNormalization.swift +++ b/Sources/ClaudeNotificationTypeNormalization.swift @@ -1,6 +1,10 @@ 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) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 68d36c82d223..23bb8cdc91ea 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5186,7 +5186,7 @@ final class TerminalSurface: Identifiable, ObservableObject { setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } setManagedEnvironmentValue( - "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES", + ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey, ClaudeCodeIntegrationSettings.ignoredNotificationTypes().joined(separator: ",") ) if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 95e087710a95..d4e3028b5f6d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4651,8 +4651,8 @@ enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true static let customClaudePathKey = "claudeCodeCustomClaudePath" - static let ignoredNotificationTypesKey = "claudeCodeIgnoredNotificationTypes" - static let defaultIgnoredNotificationTypes: [String] = [] + static let ignoredNotificationTypesKey = ClaudeNotificationTypeNormalization.ignoredTypesDefaultsKey + static let defaultIgnoredNotificationTypes = ClaudeNotificationTypeNormalization.defaultIgnoredTypes static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: hooksEnabledKey) == nil { From a22d3b62d2c4a5da87b36c45feb4f5cdeaef246e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:17:58 -0700 Subject: [PATCH 06/14] fix: sanitize suppressed notification telemetry --- CLI/cmux.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ae435a4e5cef..d16e7bc07ee3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16875,10 +16875,11 @@ struct CMUXCLI { case "notification", "notify": telemetry.breadcrumb("claude-hook.notification") let notificationTypes = Self.claudeNotificationTypes(parsedInput: parsedInput) - if Self.shouldSuppressClaudeNotification(types: notificationTypes) { + let suppressedNotificationTypes = Self.suppressedClaudeNotificationTypes(for: notificationTypes) + if !suppressedNotificationTypes.isEmpty { telemetry.breadcrumb( "claude-hook.notification.suppressed", - data: ["types": notificationTypes.sorted().joined(separator: ",")] + data: ["types": suppressedNotificationTypes.sorted().joined(separator: ",")] ) print("OK") return @@ -18914,12 +18915,12 @@ struct CMUXCLI { private static let claudeIgnoredNotificationTypesEnvKey = ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey - private static func shouldSuppressClaudeNotification(types: Set) -> Bool { + private static func suppressedClaudeNotificationTypes(for types: Set) -> Set { let ignoredTypes = ignoredClaudeNotificationTypes() guard !ignoredTypes.isEmpty, !types.isEmpty else { - return false + return [] } - return !ignoredTypes.isDisjoint(with: types) + return ignoredTypes.intersection(types) } private static func ignoredClaudeNotificationTypes() -> Set { From 1a010d25463b4dc174c9732cd1bf792eb1071e2b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:02:36 -0700 Subject: [PATCH 07/14] fix: parse fallback Claude notification types --- CLI/cmux.swift | 52 +++++++++++++--- ..._claude_hook_ignored_notification_types.py | 60 ++++++++++++++++++- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d16e7bc07ee3..e53ed36a6bf0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -371,6 +371,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? @@ -17500,7 +17501,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 @@ -17509,6 +17510,7 @@ struct CMUXCLI { rawObject: nil, object: nil, rawFallback: fallback, + rawFallbackNotificationTypeValues: Self.claudeNotificationTypeValues(inRawFallback: trimmed), sessionId: nil, turnId: nil, cwd: nil, @@ -17525,6 +17527,7 @@ struct CMUXCLI { rawObject: object, object: compactObject, rawFallback: nil, + rawFallbackNotificationTypeValues: [], sessionId: sessionId, turnId: turnId, cwd: cwd, @@ -17532,6 +17535,19 @@ struct CMUXCLI { ) } + 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] = [:] @@ -18944,17 +18960,39 @@ struct CMUXCLI { private static func claudeNotificationTypes(parsedInput: ClaudeHookParsedInput) -> Set { var rawValues: [String] = [] if let object = parsedInput.rawObject ?? parsedInput.object { - rawValues.append(contentsOf: claudeNotificationTypeValues(in: object)) + rawValues.append(contentsOf: claudeNotificationTypeValues(inJSONValue: object)) + } + rawValues.append(contentsOf: parsedInput.rawFallbackNotificationTypeValues) + return normalizedClaudeNotificationTypes(rawValues) + } + + private static func claudeNotificationTypeValues(inRawFallback fallback: String) -> [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) + } + + private static func claudeNotificationTypeValues(inJSONValue value: Any) -> [String] { + if let object = value as? [String: Any] { + var values = claudeNotificationTypeValues(in: object) for key in ["notification", "data"] { - if let nested = object[key] as? [String: Any] { - rawValues.append(contentsOf: claudeNotificationTypeValues(in: nested)) + if let nested = object[key] { + values.append(contentsOf: claudeNotificationTypeValues(inJSONValue: nested)) } } + return values } - if let fallback = parsedInput.rawFallback { - rawValues.append(fallback) + if let values = value as? [Any] { + return values.flatMap { claudeNotificationTypeValues(inJSONValue: $0) } } - return normalizedClaudeNotificationTypes(rawValues) + if let string = value as? String { + return claudeNotificationTypeValues(inRawFallback: string) + } + return [] } private static func claudeNotificationTypeValues(in object: [String: Any]) -> [String] { diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index ccf3a13370b2..b0d7edb66116 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -119,7 +119,7 @@ def run_notification_hook( cli_path: str, server: CapturingSocketServer, env: dict[str, str], - payload: dict, + payload: object, ) -> subprocess.CompletedProcess[str]: return subprocess.run( [cli_path, "--socket", server.socket_path, "claude-hook", "notification"], @@ -208,6 +208,64 @@ def main() -> int: print(f"commands={server.commands!r}") return 1 + json_string_idle = run_notification_hook( + cli_path, + server, + env, + json.dumps( + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Claude is waiting from a raw JSON string payload", + } + ), + ) + if json_string_idle.returncode != 0 or json_string_idle.stdout.strip() != "OK": + print("FAIL: JSON-string ignored idle prompt hook did not complete cleanly") + print(f"stdout={json_string_idle.stdout!r}") + print(f"stderr={json_string_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + json_string_rendered_commands = [ + line for line in server.commands + if line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + ] + if json_string_rendered_commands: + print("FAIL: JSON-string idle_prompt rendered despite ignored notification type") + print(f"commands={server.commands!r}") + return 1 + + raw_fallback_idle = run_notification_hook( + cli_path, + server, + env, + [ + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Claude is waiting from a fallback JSON array payload", + } + ], + ) + if raw_fallback_idle.returncode != 0 or raw_fallback_idle.stdout.strip() != "OK": + print("FAIL: fallback JSON ignored idle prompt hook did not complete cleanly") + print(f"stdout={raw_fallback_idle.stdout!r}") + print(f"stderr={raw_fallback_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + raw_fallback_rendered_commands = [ + line for line in server.commands + if line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + ] + if raw_fallback_rendered_commands: + print("FAIL: fallback JSON idle_prompt rendered despite ignored notification type") + print(f"commands={server.commands!r}") + return 1 + before_permission_count = len(server.commands) permission = run_notification_hook( cli_path, From 183a71a38da68941604e4ecc1d4a9e108e2b64bc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:11:07 -0700 Subject: [PATCH 08/14] fix: scope Claude notification type extraction --- CLI/cmux.swift | 44 +++++++++++---- ..._claude_hook_ignored_notification_types.py | 54 +++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e53ed36a6bf0..24d60adb75ab 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18966,39 +18966,61 @@ struct CMUXCLI { return normalizedClaudeNotificationTypes(rawValues) } - private static func claudeNotificationTypeValues(inRawFallback fallback: String) -> [String] { + 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) + return claudeNotificationTypeValues(inJSONValue: json, scope: scope) } - private static func claudeNotificationTypeValues(inJSONValue value: Any) -> [String] { + private static func claudeNotificationTypeValues( + inJSONValue value: Any, + scope: ClaudeNotificationTypeExtractionScope = .root + ) -> [String] { if let object = value as? [String: Any] { - var values = claudeNotificationTypeValues(in: object) + var values = claudeNotificationTypeValues(in: object, scope: scope) for key in ["notification", "data"] { if let nested = object[key] { - values.append(contentsOf: claudeNotificationTypeValues(inJSONValue: nested)) + values.append(contentsOf: claudeNotificationTypeValues( + inJSONValue: nested, + scope: .notificationPayload + )) } } return values } if let values = value as? [Any] { - return values.flatMap { claudeNotificationTypeValues(inJSONValue: $0) } + return values.flatMap { claudeNotificationTypeValues(inJSONValue: $0, scope: scope) } } if let string = value as? String { - return claudeNotificationTypeValues(inRawFallback: string) + return claudeNotificationTypeValues(inRawFallback: string, scope: scope) } return [] } - private static func claudeNotificationTypeValues(in object: [String: Any]) -> [String] { - [ - "notification_type", "matcher", "reason", "type", "kind" - ].compactMap { key in + 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 } } diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index b0d7edb66116..d9cfea141c76 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -208,6 +208,35 @@ def main() -> int: print(f"commands={server.commands!r}") return 1 + nested_type_idle = run_notification_hook( + cli_path, + server, + env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification": { + "type": "idle_prompt", + "message": "Claude is waiting from a nested type field", + }, + }, + ) + if nested_type_idle.returncode != 0 or nested_type_idle.stdout.strip() != "OK": + print("FAIL: nested type-field ignored idle prompt hook did not complete cleanly") + print(f"stdout={nested_type_idle.stdout!r}") + print(f"stderr={nested_type_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + nested_type_rendered_commands = [ + line for line in server.commands + if line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + ] + if nested_type_rendered_commands: + print("FAIL: nested type-field idle_prompt rendered despite ignored notification type") + print(f"commands={server.commands!r}") + return 1 + json_string_idle = run_notification_hook( cli_path, server, @@ -295,6 +324,31 @@ def main() -> int: print(f"new_commands={new_commands!r}") return 1 + before_root_type_count = len(server.commands) + root_type = run_notification_hook( + cli_path, + server, + env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "type": "idle_prompt", + "message": "Generic root type should not be treated as a notification subtype", + }, + ) + if root_type.returncode != 0: + print("FAIL: generic root type notification hook failed") + print(f"stdout={root_type.stdout!r}") + print(f"stderr={root_type.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + root_type_commands = server.commands[before_root_type_count:] + if not any(line.startswith("notify_target_async ") for line in root_type_commands): + print("FAIL: generic root type was treated as a notification type") + print(f"root_type_commands={root_type_commands!r}") + return 1 + hook_event_env = env.copy() hook_event_env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "notification" before_hook_event_count = len(server.commands) From 57574ad4cd29a881065bddc3dd5d316e4977cbd8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:26:59 -0700 Subject: [PATCH 09/14] fix: read live Claude notification settings --- CLI/cmux.swift | 125 ++++++++++++++++++ ..._claude_hook_ignored_notification_types.py | 75 +++++++++++ 2 files changed, 200 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bb736a6a1c86..12011a49fca3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18762,6 +18762,12 @@ struct CMUXCLI { } private static func ignoredClaudeNotificationTypes() -> Set { + // Hook subprocess environments are immutable after terminal launch; read cmux.json first + // so existing terminals follow live settings changes. + if let settingsTypes = ignoredClaudeNotificationTypesFromSettingsFiles() { + return settingsTypes + } + if let env = ProcessInfo.processInfo.environment[claudeIgnoredNotificationTypesEnvKey] { return normalizedClaudeNotificationTypes( env.components(separatedBy: CharacterSet(charactersIn: ", \n\t")) @@ -18779,6 +18785,125 @@ struct CMUXCLI { return [] } + private enum ClaudeIgnoredNotificationTypesFileLoadResult { + case missing + case invalid + case parsed([String]?) + } + + private static func ignoredClaudeNotificationTypesFromSettingsFiles( + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Set? { + let paths = claudeIgnoredNotificationTypesSettingsPaths( + fileManager: fileManager, + environment: environment + ) + var foundSettingsFile = false + + for (index, path) in paths.enumerated() { + switch loadIgnoredClaudeNotificationTypes(at: path, fileManager: fileManager) { + case .missing: + continue + case .invalid: + if index == 0 { + return [] + } + case .parsed(let values): + foundSettingsFile = true + if let values { + return normalizedClaudeNotificationTypes(values) + } + } + } + + return foundSettingsFile ? [] : nil + } + + private static func claudeIgnoredNotificationTypesSettingsPaths( + fileManager: FileManager, + environment: [String: String] + ) -> [String] { + var paths: [String] = [] + var seen = Set() + + func append(_ path: String) { + let standardized = URL(fileURLWithPath: path).standardizedFileURL.path + if seen.insert(standardized).inserted { + paths.append(standardized) + } + } + + append(expandedSettingsPath( + primarySettingsDisplayPath, + fileManager: fileManager, + environment: environment + )) + append(expandedSettingsPath( + legacySettingsDisplayPath, + fileManager: fileManager, + environment: environment + )) + + for appSupportURL in CmuxApplicationSupportDirectories.userDirectories( + environment: environment, + fileManager: fileManager + ) { + append( + appSupportURL + .appendingPathComponent("com.cmuxterm.app", isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + .path + ) + } + + return paths + } + + private static func expandedSettingsPath( + _ rawPath: String, + fileManager: FileManager, + environment: [String: String] + ) -> String { + let homePath = environment["HOME"] ?? fileManager.homeDirectoryForCurrentUser.path + let expanded: String + if rawPath == "~" { + expanded = homePath + } else if rawPath.hasPrefix("~/") { + expanded = (homePath as NSString).appendingPathComponent(String(rawPath.dropFirst(2))) + } else { + expanded = rawPath + } + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + + private static func loadIgnoredClaudeNotificationTypes( + at path: String, + fileManager: FileManager + ) -> ClaudeIgnoredNotificationTypesFileLoadResult { + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { + return .missing + } + guard !isDirectory.boolValue, + let data = fileManager.contents(atPath: path), + !data.isEmpty, + let sanitized = try? JSONCParser.preprocess(data: data), + let rootObject = try? JSONSerialization.jsonObject(with: sanitized, options: []), + let root = rootObject as? [String: Any] else { + return .invalid + } + + guard let notifications = root["notifications"] as? [String: Any], + let rawValues = notifications["ignoredClaudeNotificationTypes"] else { + return .parsed(nil) + } + guard let values = rawValues as? [String] else { + return .parsed(nil) + } + return .parsed(values) + } + private static func claudeNotificationTypes(parsedInput: ClaudeHookParsedInput) -> Set { var rawValues: [String] = [] if let object = parsedInput.rawObject ?? parsedInput.object { diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index d9cfea141c76..61e7bcc84d7b 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -132,6 +132,21 @@ def run_notification_hook( ) +def configure_isolated_home(server: CapturingSocketServer, env: dict[str, str]) -> str: + home = os.path.join(server.root.name, "home") + os.makedirs(home, exist_ok=True) + env["HOME"] = home + env["CFFIXED_USER_HOME"] = home + return home + + +def write_cmux_config(home: str, notifications: dict[str, object]) -> None: + config_dir = os.path.join(home, ".config", "cmux") + os.makedirs(config_dir, exist_ok=True) + with open(os.path.join(config_dir, "cmux.json"), "w", encoding="utf-8") as handle: + json.dump({"notifications": notifications}, handle) + + def main() -> int: try: cli_path = resolve_cmux_cli() @@ -151,6 +166,7 @@ def main() -> int: env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "idle_prompt" env["CMUX_CLI_SENTRY_DISABLED"] = "1" env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + home = configure_isolated_home(server, env) idle = run_notification_hook( cli_path, @@ -376,6 +392,65 @@ def main() -> int: print(f"hook_event_commands={hook_event_commands!r}") return 1 + settings_env = env.copy() + settings_env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "permission_prompt" + write_cmux_config(home, {"ignoredClaudeNotificationTypes": ["idle_prompt"]}) + before_settings_count = len(server.commands) + settings_idle = run_notification_hook( + cli_path, + server, + settings_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Current cmux.json should override stale hook env", + }, + ) + if settings_idle.returncode != 0 or settings_idle.stdout.strip() != "OK": + print("FAIL: cmux.json ignored type did not override stale hook env") + print(f"stdout={settings_idle.stdout!r}") + print(f"stderr={settings_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + settings_idle_commands = server.commands[before_settings_count:] + if any( + line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + for line in settings_idle_commands + ): + print("FAIL: cmux.json ignored type rendered because stale env won") + print(f"settings_idle_commands={settings_idle_commands!r}") + return 1 + + stale_env = env.copy() + stale_env["CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES"] = "idle_prompt" + write_cmux_config(home, {}) + before_removed_count = len(server.commands) + removed_idle = run_notification_hook( + cli_path, + server, + stale_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Removed cmux.json setting should not leave stale suppression active", + }, + ) + if removed_idle.returncode != 0: + print("FAIL: removed cmux.json ignored type hook failed") + print(f"stdout={removed_idle.stdout!r}") + print(f"stderr={removed_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + removed_idle_commands = server.commands[before_removed_count:] + if not any(line.startswith("notify_target_async ") for line in removed_idle_commands): + print("FAIL: stale hook env suppressed after cmux.json ignored types were removed") + print(f"removed_idle_commands={removed_idle_commands!r}") + return 1 + print("PASS: Claude ignored notification types suppress only matching hook notifications") return 0 From 23613f84ca6e6d6620e85d509b2b828d3b44f190 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:31:30 -0700 Subject: [PATCH 10/14] fix: skip invalid Claude notification configs --- CLI/cmux.swift | 6 ++-- ..._claude_hook_ignored_notification_types.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 12011a49fca3..692c8d3c136a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18801,14 +18801,12 @@ struct CMUXCLI { ) var foundSettingsFile = false - for (index, path) in paths.enumerated() { + for path in paths { switch loadIgnoredClaudeNotificationTypes(at: path, fileManager: fileManager) { case .missing: continue case .invalid: - if index == 0 { - return [] - } + continue case .parsed(let values): foundSettingsFile = true if let values { diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 61e7bcc84d7b..5fd3b9068edd 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -147,6 +147,13 @@ def write_cmux_config(home: str, notifications: dict[str, object]) -> None: json.dump({"notifications": notifications}, handle) +def write_raw_cmux_config(home: str, content: str) -> None: + config_dir = os.path.join(home, ".config", "cmux") + os.makedirs(config_dir, exist_ok=True) + with open(os.path.join(config_dir, "cmux.json"), "w", encoding="utf-8") as handle: + handle.write(content) + + def main() -> int: try: cli_path = resolve_cmux_cli() @@ -451,6 +458,35 @@ def main() -> int: print(f"removed_idle_commands={removed_idle_commands!r}") return 1 + write_raw_cmux_config(home, "{") + before_invalid_count = len(server.commands) + invalid_config_idle = run_notification_hook( + cli_path, + server, + stale_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Invalid cmux.json should fall back to hook env", + }, + ) + if invalid_config_idle.returncode != 0 or invalid_config_idle.stdout.strip() != "OK": + print("FAIL: invalid cmux.json did not fall back to hook env") + print(f"stdout={invalid_config_idle.stdout!r}") + print(f"stderr={invalid_config_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + invalid_config_commands = server.commands[before_invalid_count:] + if any( + line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + for line in invalid_config_commands + ): + print("FAIL: invalid cmux.json blocked hook env ignored types") + print(f"invalid_config_commands={invalid_config_commands!r}") + return 1 + print("PASS: Claude ignored notification types suppress only matching hook notifications") return 0 From 0d442bc8b506bcd6d2415e87ff9bf283171baf89 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:47:14 -0700 Subject: [PATCH 11/14] fix: honor live Claude notification config precedence --- .circleci/config.yml | 1 + .github/workflows/ci.yml | 1 + CLI/cmux.swift | 5 +-- ..._claude_hook_ignored_notification_types.py | 44 ++++++++++++++++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c8caf995817a..3bacf68e7557 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -236,6 +236,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_ignored_notification_types.py - save-swift-package-cache: prefix: unit - save-zig-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1aba00a1a38..ce5c6cf3d7cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,6 +326,7 @@ jobs: python3 tests/test_claude_wrapper_hooks.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_ignored_notification_types.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py tests-build-and-lag: diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 692c8d3c136a..4949d5dd12ce 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18799,7 +18799,6 @@ struct CMUXCLI { fileManager: fileManager, environment: environment ) - var foundSettingsFile = false for path in paths { switch loadIgnoredClaudeNotificationTypes(at: path, fileManager: fileManager) { @@ -18808,14 +18807,14 @@ struct CMUXCLI { case .invalid: continue case .parsed(let values): - foundSettingsFile = true if let values { return normalizedClaudeNotificationTypes(values) } + return [] } } - return foundSettingsFile ? [] : nil + return nil } private static func claudeIgnoredNotificationTypesSettingsPaths( diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 5fd3b9068edd..593fda432d32 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -6,7 +6,6 @@ import glob import json import os -import shutil import socket import subprocess import tempfile @@ -24,17 +23,12 @@ def resolve_cmux_cli() -> str: candidates: list[str] = [] candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) - candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) candidates = [path for path in candidates if os.path.exists(path) and os.access(path, os.X_OK)] if candidates: candidates.sort(key=os.path.getmtime, reverse=True) return candidates[0] - in_path = shutil.which("cmux") - if in_path: - return in_path - - raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + raise RuntimeError("cmux CLI binary not found") class CapturingSocketServer: @@ -147,6 +141,13 @@ def write_cmux_config(home: str, notifications: dict[str, object]) -> None: json.dump({"notifications": notifications}, handle) +def write_legacy_cmux_config(home: str, notifications: dict[str, object]) -> None: + config_dir = os.path.join(home, ".config", "cmux") + os.makedirs(config_dir, exist_ok=True) + with open(os.path.join(config_dir, "settings.json"), "w", encoding="utf-8") as handle: + json.dump({"notifications": notifications}, handle) + + def write_raw_cmux_config(home: str, content: str) -> None: config_dir = os.path.join(home, ".config", "cmux") os.makedirs(config_dir, exist_ok=True) @@ -487,6 +488,35 @@ def main() -> int: print(f"invalid_config_commands={invalid_config_commands!r}") return 1 + legacy_env = env.copy() + legacy_env.pop("CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES", None) + write_cmux_config(home, {}) + write_legacy_cmux_config(home, {"ignoredClaudeNotificationTypes": ["idle_prompt"]}) + before_legacy_count = len(server.commands) + removed_primary_idle = run_notification_hook( + cli_path, + server, + legacy_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Primary cmux.json removal should block legacy ignored types", + }, + ) + if removed_primary_idle.returncode != 0: + print("FAIL: primary removal with legacy config hook failed") + print(f"stdout={removed_primary_idle.stdout!r}") + print(f"stderr={removed_primary_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + removed_primary_commands = server.commands[before_legacy_count:] + if not any(line.startswith("notify_target_async ") for line in removed_primary_commands): + print("FAIL: lower-precedence legacy ignored types overrode removed primary setting") + print(f"removed_primary_commands={removed_primary_commands!r}") + return 1 + print("PASS: Claude ignored notification types suppress only matching hook notifications") return 0 From 270a7c7c1df15def6711a4e197a26f8feda87a63 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 20:01:15 -0700 Subject: [PATCH 12/14] fix: reject invalid Claude notification config values --- CLI/cmux.swift | 2 +- ..._claude_hook_ignored_notification_types.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4949d5dd12ce..7f883c57499e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18896,7 +18896,7 @@ struct CMUXCLI { return .parsed(nil) } guard let values = rawValues as? [String] else { - return .parsed(nil) + return .invalid } return .parsed(values) } diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 593fda432d32..2b0be73f680f 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -488,6 +488,38 @@ def main() -> int: print(f"invalid_config_commands={invalid_config_commands!r}") return 1 + write_raw_cmux_config( + home, + json.dumps({"notifications": {"ignoredClaudeNotificationTypes": "idle_prompt"}}), + ) + before_invalid_type_count = len(server.commands) + invalid_type_idle = run_notification_hook( + cli_path, + server, + stale_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Invalid ignored type value should fall back to hook env", + }, + ) + if invalid_type_idle.returncode != 0 or invalid_type_idle.stdout.strip() != "OK": + print("FAIL: wrong-type cmux.json ignored types did not fall back to hook env") + print(f"stdout={invalid_type_idle.stdout!r}") + print(f"stderr={invalid_type_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + invalid_type_commands = server.commands[before_invalid_type_count:] + if any( + line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + for line in invalid_type_commands + ): + print("FAIL: wrong-type cmux.json blocked hook env ignored types") + print(f"invalid_type_commands={invalid_type_commands!r}") + return 1 + legacy_env = env.copy() legacy_env.pop("CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES", None) write_cmux_config(home, {}) From 76ee9f0ae79abd66c62a4c3f0fd15aa6c95d5a74 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 20:09:12 -0700 Subject: [PATCH 13/14] fix: treat malformed notifications config as invalid --- CLI/cmux.swift | 9 ++++-- ..._claude_hook_ignored_notification_types.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7f883c57499e..515e626fab27 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18891,8 +18891,13 @@ struct CMUXCLI { return .invalid } - guard let notifications = root["notifications"] as? [String: Any], - let rawValues = notifications["ignoredClaudeNotificationTypes"] else { + guard let rawNotifications = root["notifications"] else { + return .parsed(nil) + } + guard let notifications = rawNotifications as? [String: Any] else { + return .invalid + } + guard let rawValues = notifications["ignoredClaudeNotificationTypes"] else { return .parsed(nil) } guard let values = rawValues as? [String] else { diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 2b0be73f680f..39f6a9fef7af 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -520,6 +520,35 @@ def main() -> int: print(f"invalid_type_commands={invalid_type_commands!r}") return 1 + write_raw_cmux_config(home, json.dumps({"notifications": "idle_prompt"})) + before_malformed_notifications_count = len(server.commands) + malformed_notifications_idle = run_notification_hook( + cli_path, + server, + stale_env, + { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "Malformed notifications block should fall back to hook env", + }, + ) + if malformed_notifications_idle.returncode != 0 or malformed_notifications_idle.stdout.strip() != "OK": + print("FAIL: malformed notifications block did not fall back to hook env") + print(f"stdout={malformed_notifications_idle.stdout!r}") + print(f"stderr={malformed_notifications_idle.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + malformed_notifications_commands = server.commands[before_malformed_notifications_count:] + if any( + line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") + for line in malformed_notifications_commands + ): + print("FAIL: malformed notifications block blocked hook env ignored types") + print(f"malformed_notifications_commands={malformed_notifications_commands!r}") + return 1 + legacy_env = env.copy() legacy_env.pop("CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES", None) write_cmux_config(home, {}) From 4c7ba93b2b85c64026ce4a9e48e72c586af8158a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:02:11 -0700 Subject: [PATCH 14/14] refactor: isolate Claude notification domain --- CLI/cmux.swift | 193 ++------------- .../CMUXClaudeNotifications/Package.swift | 24 ++ .../ClaudeNotifications.swift | 221 ++++++++++++++++++ .../ClaudeNotificationsTests.swift | 86 +++++++ .../ClaudeNotificationTypeNormalization.swift | 34 --- Sources/GhosttyTerminalView.swift | 1 + Sources/cmuxApp.swift | 1 + cmux.xcodeproj/project.pbxproj | 22 +- docs/notifications.md | 1 + ..._claude_hook_ignored_notification_types.py | 33 +-- web/data/cmux.schema.json | 10 + 11 files changed, 390 insertions(+), 236 deletions(-) create mode 100644 Packages/CMUXClaudeNotifications/Package.swift create mode 100644 Packages/CMUXClaudeNotifications/Sources/CMUXClaudeNotifications/ClaudeNotifications.swift create mode 100644 Packages/CMUXClaudeNotifications/Tests/CMUXClaudeNotificationsTests/ClaudeNotificationsTests.swift delete mode 100644 Sources/ClaudeNotificationTypeNormalization.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 515e626fab27..9b9c6d1ddcb2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import CMUXAgentLaunch +import CMUXClaudeNotifications import CoreFoundation import CryptoKit import Darwin @@ -17332,7 +17333,7 @@ struct CMUXCLI { rawObject: nil, object: nil, rawFallback: fallback, - rawFallbackNotificationTypeValues: Self.claudeNotificationTypeValues(inRawFallback: trimmed), + rawFallbackNotificationTypeValues: ClaudeNotificationTypeExtractor.values(inRawFallback: trimmed), sessionId: nil, turnId: nil, cwd: nil, @@ -18754,11 +18755,10 @@ struct CMUXCLI { ClaudeNotificationTypeNormalization.ignoredTypesEnvironmentKey private static func suppressedClaudeNotificationTypes(for types: Set) -> Set { - let ignoredTypes = ignoredClaudeNotificationTypes() - guard !ignoredTypes.isEmpty, !types.isEmpty else { - return [] - } - return ignoredTypes.intersection(types) + ClaudeNotificationSuppression.suppressedTypes( + notificationTypes: types, + ignoredTypes: ignoredClaudeNotificationTypes() + ) } private static func ignoredClaudeNotificationTypes() -> Set { @@ -18785,195 +18785,38 @@ struct CMUXCLI { return [] } - private enum ClaudeIgnoredNotificationTypesFileLoadResult { - case missing - case invalid - case parsed([String]?) - } - private static func ignoredClaudeNotificationTypesFromSettingsFiles( fileManager: FileManager = .default, environment: [String: String] = ProcessInfo.processInfo.environment ) -> Set? { - let paths = claudeIgnoredNotificationTypesSettingsPaths( + let paths = ClaudeIgnoredNotificationSettings.settingsPaths( + primaryDisplayPath: primarySettingsDisplayPath, + legacyDisplayPath: legacySettingsDisplayPath, + appSupportDirectories: CmuxApplicationSupportDirectories.userDirectories( + environment: environment, + fileManager: fileManager + ), fileManager: fileManager, environment: environment ) - for path in paths { - switch loadIgnoredClaudeNotificationTypes(at: path, fileManager: fileManager) { - case .missing: - continue - case .invalid: - continue - case .parsed(let values): - if let values { - return normalizedClaudeNotificationTypes(values) - } - return [] - } - } - - return nil - } - - private static func claudeIgnoredNotificationTypesSettingsPaths( - fileManager: FileManager, - environment: [String: String] - ) -> [String] { - var paths: [String] = [] - var seen = Set() - - func append(_ path: String) { - let standardized = URL(fileURLWithPath: path).standardizedFileURL.path - if seen.insert(standardized).inserted { - paths.append(standardized) - } - } - - append(expandedSettingsPath( - primarySettingsDisplayPath, - fileManager: fileManager, - environment: environment - )) - append(expandedSettingsPath( - legacySettingsDisplayPath, - fileManager: fileManager, - environment: environment - )) - - for appSupportURL in CmuxApplicationSupportDirectories.userDirectories( - environment: environment, + return ClaudeIgnoredNotificationSettings.ignoredTypesFromSettingsFiles( + paths: paths, fileManager: fileManager - ) { - append( - appSupportURL - .appendingPathComponent("com.cmuxterm.app", isDirectory: true) - .appendingPathComponent("settings.json", isDirectory: false) - .path - ) - } - - return paths - } - - private static func expandedSettingsPath( - _ rawPath: String, - fileManager: FileManager, - environment: [String: String] - ) -> String { - let homePath = environment["HOME"] ?? fileManager.homeDirectoryForCurrentUser.path - let expanded: String - if rawPath == "~" { - expanded = homePath - } else if rawPath.hasPrefix("~/") { - expanded = (homePath as NSString).appendingPathComponent(String(rawPath.dropFirst(2))) - } else { - expanded = rawPath + ) { data in + try JSONCParser.preprocess(data: data) } - return URL(fileURLWithPath: expanded).standardizedFileURL.path - } - - private static func loadIgnoredClaudeNotificationTypes( - at path: String, - fileManager: FileManager - ) -> ClaudeIgnoredNotificationTypesFileLoadResult { - var isDirectory = ObjCBool(false) - guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { - return .missing - } - guard !isDirectory.boolValue, - let data = fileManager.contents(atPath: path), - !data.isEmpty, - let sanitized = try? JSONCParser.preprocess(data: data), - let rootObject = try? JSONSerialization.jsonObject(with: sanitized, options: []), - let root = rootObject as? [String: Any] else { - return .invalid - } - - guard let rawNotifications = root["notifications"] else { - return .parsed(nil) - } - guard let notifications = rawNotifications as? [String: Any] else { - return .invalid - } - guard let rawValues = notifications["ignoredClaudeNotificationTypes"] else { - return .parsed(nil) - } - guard let values = rawValues as? [String] else { - return .invalid - } - return .parsed(values) } private static func claudeNotificationTypes(parsedInput: ClaudeHookParsedInput) -> Set { var rawValues: [String] = [] if let object = parsedInput.rawObject ?? parsedInput.object { - rawValues.append(contentsOf: claudeNotificationTypeValues(inJSONValue: object)) + rawValues.append(contentsOf: ClaudeNotificationTypeExtractor.values(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 [] - } - - 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 { ClaudeNotificationTypeNormalization.normalizedSet(values) } diff --git a/Packages/CMUXClaudeNotifications/Package.swift b/Packages/CMUXClaudeNotifications/Package.swift new file mode 100644 index 000000000000..b89c9c15b750 --- /dev/null +++ b/Packages/CMUXClaudeNotifications/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CMUXClaudeNotifications", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "CMUXClaudeNotifications", + targets: ["CMUXClaudeNotifications"] + ), + ], + targets: [ + .target( + name: "CMUXClaudeNotifications" + ), + .testTarget( + name: "CMUXClaudeNotificationsTests", + dependencies: ["CMUXClaudeNotifications"] + ), + ] +) diff --git a/Packages/CMUXClaudeNotifications/Sources/CMUXClaudeNotifications/ClaudeNotifications.swift b/Packages/CMUXClaudeNotifications/Sources/CMUXClaudeNotifications/ClaudeNotifications.swift new file mode 100644 index 000000000000..2652bb287ea9 --- /dev/null +++ b/Packages/CMUXClaudeNotifications/Sources/CMUXClaudeNotifications/ClaudeNotifications.swift @@ -0,0 +1,221 @@ +import Foundation + +public enum ClaudeNotificationTypeNormalization { + public static let ignoredTypesDefaultsKey = "claudeCodeIgnoredNotificationTypes" + public static let ignoredTypesEnvironmentKey = "CMUX_CLAUDE_IGNORED_NOTIFICATION_TYPES" + public static let defaultIgnoredTypes: [String] = [] + + public 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 + } + + public static func normalizedSet(_ values: [String]) -> Set { + Set(values.compactMap(normalized)) + } + + public static func normalizedUniqueList(_ values: [String]) -> [String] { + var seen = Set() + return values.compactMap { raw in + guard let normalized = normalized(raw), + seen.insert(normalized).inserted else { + return nil + } + return normalized + } + } +} + +public enum ClaudeNotificationTypeExtractionScope { + case root + case notificationPayload +} + +public enum ClaudeNotificationTypeExtractor { + public static func values( + 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 values(inJSONValue: json, scope: scope) + } + + public static func values( + inJSONValue value: Any, + scope: ClaudeNotificationTypeExtractionScope = .root + ) -> [String] { + if let object = value as? [String: Any] { + var values = values(in: object, scope: scope) + for key in ["notification", "data"] { + if let nested = object[key] { + values.append(contentsOf: self.values( + inJSONValue: nested, + scope: .notificationPayload + )) + } + } + return values + } + if let values = value as? [Any] { + return values.flatMap { self.values(inJSONValue: $0, scope: scope) } + } + if let string = value as? String { + return values(inRawFallback: string, scope: scope) + } + return [] + } + + private static func values( + 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 + } + } +} + +public enum ClaudeIgnoredNotificationTypesFileLoadResult: Equatable { + case missing + case invalid + case parsed([String]?) +} + +public enum ClaudeIgnoredNotificationSettings { + public typealias DataPreprocessor = (Data) throws -> Data + + public static func ignoredTypesFromSettingsFiles( + paths: [String], + fileManager: FileManager = .default, + preprocess: DataPreprocessor = { $0 } + ) -> Set? { + for path in paths { + switch load(at: path, fileManager: fileManager, preprocess: preprocess) { + case .missing: + continue + case .invalid: + return [] + case .parsed(let values): + guard let values else { + return [] + } + return ClaudeNotificationTypeNormalization.normalizedSet(values) + } + } + + return nil + } + + public static func settingsPaths( + primaryDisplayPath: String, + legacyDisplayPath: String, + appSupportDirectories: [URL], + fileManager: FileManager = .default, + environment: [String: String] + ) -> [String] { + var paths: [String] = [] + var seen = Set() + + func append(_ path: String) { + let standardized = URL(fileURLWithPath: path).standardizedFileURL.path + if seen.insert(standardized).inserted { + paths.append(standardized) + } + } + + append(expandedSettingsPath(primaryDisplayPath, fileManager: fileManager, environment: environment)) + append(expandedSettingsPath(legacyDisplayPath, fileManager: fileManager, environment: environment)) + + for appSupportURL in appSupportDirectories { + append( + appSupportURL + .appendingPathComponent("com.cmuxterm.app", isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + .path + ) + } + + return paths + } + + public static func expandedSettingsPath( + _ rawPath: String, + fileManager: FileManager = .default, + environment: [String: String] + ) -> String { + let homePath = environment["HOME"] ?? fileManager.homeDirectoryForCurrentUser.path + let expanded: String + if rawPath == "~" { + expanded = homePath + } else if rawPath.hasPrefix("~/") { + expanded = (homePath as NSString).appendingPathComponent(String(rawPath.dropFirst(2))) + } else { + expanded = rawPath + } + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + + public static func load( + at path: String, + fileManager: FileManager = .default, + preprocess: DataPreprocessor = { $0 } + ) -> ClaudeIgnoredNotificationTypesFileLoadResult { + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { + return .missing + } + guard !isDirectory.boolValue, + let data = fileManager.contents(atPath: path), + !data.isEmpty, + let sanitized = try? preprocess(data), + let rootObject = try? JSONSerialization.jsonObject(with: sanitized, options: []), + let root = rootObject as? [String: Any] else { + return .invalid + } + + guard let rawNotifications = root["notifications"] else { + return .parsed(nil) + } + guard let notifications = rawNotifications as? [String: Any] else { + return .invalid + } + guard let rawValues = notifications["ignoredClaudeNotificationTypes"] else { + return .parsed(nil) + } + guard let values = rawValues as? [String] else { + return .invalid + } + return .parsed(values) + } +} + +public enum ClaudeNotificationSuppression { + public static func suppressedTypes( + notificationTypes: Set, + ignoredTypes: Set + ) -> Set { + guard !ignoredTypes.isEmpty, !notificationTypes.isEmpty else { + return [] + } + return ignoredTypes.intersection(notificationTypes) + } +} diff --git a/Packages/CMUXClaudeNotifications/Tests/CMUXClaudeNotificationsTests/ClaudeNotificationsTests.swift b/Packages/CMUXClaudeNotifications/Tests/CMUXClaudeNotificationsTests/ClaudeNotificationsTests.swift new file mode 100644 index 000000000000..db8300aaf522 --- /dev/null +++ b/Packages/CMUXClaudeNotifications/Tests/CMUXClaudeNotificationsTests/ClaudeNotificationsTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest +@testable import CMUXClaudeNotifications + +final class ClaudeNotificationsTests: XCTestCase { + func testNotificationTypeNormalizationIsCaseAndSeparatorInsensitive() { + XCTAssertEqual( + ClaudeNotificationTypeNormalization.normalizedUniqueList([ + " idle prompt ", + "IDLE-PROMPT", + "permission_prompt", + "", + ]), + ["idle_prompt", "permission_prompt"] + ) + } + + func testNotificationTypeExtractionUsesNotificationPayloadTypesOnlyForGenericTypeKeys() { + let root: [String: Any] = [ + "hook_event_name": "Notification", + "type": "notification", + "notification": [ + "type": "idle_prompt", + "kind": "permission_prompt", + ], + "data": "{\"reason\":\"tool_use\"}", + ] + + XCTAssertEqual( + Set(ClaudeNotificationTypeExtractor.values(inJSONValue: root)), + Set(["idle_prompt", "permission_prompt", "tool_use"]) + ) + } + + func testCurrentInvalidSettingsFileWinsAsEmptySuppressionSet() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let primary = directory.appendingPathComponent("cmux.json") + let legacy = directory.appendingPathComponent("settings.json") + try "{".write(to: primary, atomically: true, encoding: .utf8) + try #"{"notifications":{"ignoredClaudeNotificationTypes":["idle_prompt"]}}"# + .write(to: legacy, atomically: true, encoding: .utf8) + + XCTAssertEqual( + ClaudeIgnoredNotificationSettings.ignoredTypesFromSettingsFiles( + paths: [primary.path, legacy.path] + ), + [] + ) + } + + func testCurrentMissingSettingWinsAsEmptySuppressionSet() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let primary = directory.appendingPathComponent("cmux.json") + let legacy = directory.appendingPathComponent("settings.json") + try #"{"notifications":{}}"#.write(to: primary, atomically: true, encoding: .utf8) + try #"{"notifications":{"ignoredClaudeNotificationTypes":["idle_prompt"]}}"# + .write(to: legacy, atomically: true, encoding: .utf8) + + XCTAssertEqual( + ClaudeIgnoredNotificationSettings.ignoredTypesFromSettingsFiles( + paths: [primary.path, legacy.path] + ), + [] + ) + } + + func testMissingSettingsFilesReturnNilForEnvironmentFallback() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + XCTAssertNil( + ClaudeIgnoredNotificationSettings.ignoredTypesFromSettingsFiles( + paths: [directory.appendingPathComponent("missing.json").path] + ) + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-notifications-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Sources/ClaudeNotificationTypeNormalization.swift b/Sources/ClaudeNotificationTypeNormalization.swift deleted file mode 100644 index bdd530bf0326..000000000000 --- a/Sources/ClaudeNotificationTypeNormalization.swift +++ /dev/null @@ -1,34 +0,0 @@ -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 { - Set(values.compactMap(normalized)) - } - - static func normalizedUniqueList(_ values: [String]) -> [String] { - var seen = Set() - return values.compactMap { raw in - guard let normalized = normalized(raw), - seen.insert(normalized).inserted else { - return nil - } - return normalized - } - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index bc4d0400a30f..dc75edb14c93 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10,6 +10,7 @@ import Carbon.HIToolbox import Sentry import Bonsplit import CMUXAgentLaunch +import CMUXClaudeNotifications import CMUXPasteboardFidelity import IOSurface import UniformTypeIdentifiers diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e055926ea3a4..22e3840d2905 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import Darwin import Bonsplit +import CMUXClaudeNotifications import UniformTypeIdentifiers @main struct cmuxApp: App { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index afaa2405e8bf..33351292a558 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -49,8 +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 */; }; + C1A6D001C1A6D001C1A6D001 /* CMUXClaudeNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = C1A6D004C1A6D004C1A6D004 /* CMUXClaudeNotifications */; }; + C1A6D002C1A6D002C1A6D002 /* CMUXClaudeNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = C1A6D004C1A6D004C1A6D004 /* CMUXClaudeNotifications */; }; 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 */; }; @@ -895,7 +895,6 @@ C34670030000000000000002 /* KeyboardShortcutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContext.swift; sourceTree = ""; }; C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarPerformanceSupportTests.swift; sourceTree = ""; }; C0DEF0B10000000000000002 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; - D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationTypeNormalization.swift; sourceTree = ""; }; AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeHeightUITests.swift; sourceTree = ""; }; B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; @@ -1041,6 +1040,7 @@ F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */, A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */, + C1A6D001C1A6D001C1A6D001 /* CMUXClaudeNotifications in Frameworks */, F20F85FC5900550685FA33AD /* StackAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1059,6 +1059,7 @@ B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, B900004CA1B2C3D4E5F60719 /* CMUXSocketPathDomain in Frameworks */, + C1A6D002C1A6D002C1A6D002 /* CMUXClaudeNotifications in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1257,7 +1258,6 @@ A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */, C0DEF0B10000000000000002 /* JSONCParser.swift */, - D0265CE28BC54BD683AC48A8 /* ClaudeNotificationTypeNormalization.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */, @@ -1662,6 +1662,7 @@ F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, + C1A6D004C1A6D004C1A6D004 /* CMUXClaudeNotifications */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, A8BD195031FC4B82B4354297 /* StackAuth */, ); @@ -1685,6 +1686,7 @@ A5001251 /* Sentry */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, + C1A6D004C1A6D004C1A6D004 /* CMUXClaudeNotifications */, ); productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -1795,6 +1797,7 @@ A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */, + C1A6D003C1A6D003C1A6D003 /* XCLocalSwiftPackageReference "CMUXClaudeNotifications" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); productRefGroup = A5001042 /* Products */; @@ -1986,7 +1989,6 @@ 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 */, @@ -2223,7 +2225,6 @@ 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 */, ); @@ -2819,6 +2820,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXSocketPathDomain; }; + C1A6D003C1A6D003C1A6D003 /* XCLocalSwiftPackageReference "CMUXClaudeNotifications" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CMUXClaudeNotifications; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -2896,6 +2901,11 @@ package = A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */; productName = CMUXSocketPathDomain; }; + C1A6D004C1A6D004C1A6D004 /* CMUXClaudeNotifications */ = { + isa = XCSwiftPackageProductDependency; + package = C1A6D003C1A6D003C1A6D003 /* XCLocalSwiftPackageReference "CMUXClaudeNotifications" */; + productName = CMUXClaudeNotifications; + }; A5001231 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/docs/notifications.md b/docs/notifications.md index 0945aae4174a..d135e75bdd2f 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -126,6 +126,7 @@ To keep Claude Code integration on but suppress recurring idle prompt notificati ``` This only skips Claude Code `Notification` hook rendering for matching notification types. Stop notifications and per-workspace running/stopped status still work. +For already-open terminals, the current `cmux.json` value wins over the terminal's launch-time environment; if the current file exists but omits or invalidates `ignoredClaudeNotificationTypes`, cmux treats the ignored set as empty. ### GitHub Copilot CLI diff --git a/tests/test_claude_hook_ignored_notification_types.py b/tests/test_claude_hook_ignored_notification_types.py index 39f6a9fef7af..8628ed5797b3 100644 --- a/tests/test_claude_hook_ignored_notification_types.py +++ b/tests/test_claude_hook_ignored_notification_types.py @@ -469,22 +469,19 @@ def main() -> int: "session_id": f"sess-{uuid.uuid4().hex}", "hook_event_name": "Notification", "notification_type": "idle_prompt", - "message": "Invalid cmux.json should fall back to hook env", + "message": "Invalid cmux.json should not leave stale suppression active", }, ) if invalid_config_idle.returncode != 0 or invalid_config_idle.stdout.strip() != "OK": - print("FAIL: invalid cmux.json did not fall back to hook env") + print("FAIL: invalid cmux.json hook failed") print(f"stdout={invalid_config_idle.stdout!r}") print(f"stderr={invalid_config_idle.stderr!r}") print(f"commands={server.commands!r}") return 1 invalid_config_commands = server.commands[before_invalid_count:] - if any( - line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") - for line in invalid_config_commands - ): - print("FAIL: invalid cmux.json blocked hook env ignored types") + if not any(line.startswith("notify_target_async ") for line in invalid_config_commands): + print("FAIL: stale hook env suppressed after cmux.json became invalid") print(f"invalid_config_commands={invalid_config_commands!r}") return 1 @@ -501,22 +498,19 @@ def main() -> int: "session_id": f"sess-{uuid.uuid4().hex}", "hook_event_name": "Notification", "notification_type": "idle_prompt", - "message": "Invalid ignored type value should fall back to hook env", + "message": "Invalid ignored type value should not leave stale suppression active", }, ) if invalid_type_idle.returncode != 0 or invalid_type_idle.stdout.strip() != "OK": - print("FAIL: wrong-type cmux.json ignored types did not fall back to hook env") + print("FAIL: wrong-type cmux.json ignored types hook failed") print(f"stdout={invalid_type_idle.stdout!r}") print(f"stderr={invalid_type_idle.stderr!r}") print(f"commands={server.commands!r}") return 1 invalid_type_commands = server.commands[before_invalid_type_count:] - if any( - line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") - for line in invalid_type_commands - ): - print("FAIL: wrong-type cmux.json blocked hook env ignored types") + if not any(line.startswith("notify_target_async ") for line in invalid_type_commands): + print("FAIL: stale hook env suppressed after cmux.json ignored types became wrong-type") print(f"invalid_type_commands={invalid_type_commands!r}") return 1 @@ -530,22 +524,19 @@ def main() -> int: "session_id": f"sess-{uuid.uuid4().hex}", "hook_event_name": "Notification", "notification_type": "idle_prompt", - "message": "Malformed notifications block should fall back to hook env", + "message": "Malformed notifications block should not leave stale suppression active", }, ) if malformed_notifications_idle.returncode != 0 or malformed_notifications_idle.stdout.strip() != "OK": - print("FAIL: malformed notifications block did not fall back to hook env") + print("FAIL: malformed notifications block hook failed") print(f"stdout={malformed_notifications_idle.stdout!r}") print(f"stderr={malformed_notifications_idle.stderr!r}") print(f"commands={server.commands!r}") return 1 malformed_notifications_commands = server.commands[before_malformed_notifications_count:] - if any( - line.startswith("notify_target_async ") or line.startswith("set_status claude_code Needs input ") - for line in malformed_notifications_commands - ): - print("FAIL: malformed notifications block blocked hook env ignored types") + if not any(line.startswith("notify_target_async ") for line in malformed_notifications_commands): + print("FAIL: stale hook env suppressed after cmux.json notifications block became malformed") print(f"malformed_notifications_commands={malformed_notifications_commands!r}") return 1 diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b2b0d26429be..88b2a062f94d 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -378,6 +378,16 @@ "default": "", "description": "Optional shell command to run alongside notification delivery." }, + "ignoredClaudeNotificationTypes": { + "type": "array", + "default": [], + "description": "Claude Code notification_type values to suppress while keeping the integration enabled.", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "hooksMode": { "type": "string", "enum": ["append", "replace"],