From 942c2ff975a931947558f62a9a1e746a9b5addfa Mon Sep 17 00:00:00 2001 From: Kevinlee49 Date: Wed, 11 Nov 2026 08:36:47 -0600 Subject: [PATCH 1/2] fix(windows): support Claude notification alert settings Addresses #54. --- .github/workflows/windows-test.yml | 32 +++++++++++++ README.md | 4 +- docs/installation.md | 2 + scripts/install-windows.ps1 | 75 ++++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml index eb26423..d0f8286 100644 --- a/.github/workflows/windows-test.yml +++ b/.github/workflows/windows-test.yml @@ -235,6 +235,38 @@ jobs: } Write-Host "OK: Codex notifications disabled" -ForegroundColor Green + - name: Test Claude alert configuration + run: | + $cn = "$env:USERPROFILE\.code-notify\bin\cn.ps1" + $settingsFile = "$env:USERPROFILE\.claude\settings.json" + $status = & $cn alerts 6>&1 | Out-String + if ($status -notmatch 'Matcher: idle_prompt') { + throw "cn alerts did not report the default Claude matcher" + } + + & $cn alerts add permission_prompt + if ((Test-Path $settingsFile) -and (Get-Content $settingsFile -Raw | ConvertFrom-Json).hooks) { + throw "cn alerts enabled Claude hooks before cn on claude" + } + & $cn on claude + $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json + if ($settings.hooks.Notification.matcher -cne 'idle_prompt|permission_prompt') { + throw "cn on claude did not use the saved alert types" + } + + & $cn alerts remove idle_prompt + $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json + if ($settings.hooks.Notification.matcher -cne 'permission_prompt' -or -not $settings.hooks.Stop) { + throw "cn alerts remove did not update the matcher and preserve Stop" + } + + & $cn alerts reset + $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json + if ($settings.hooks.Notification.matcher -cne 'idle_prompt') { + throw "cn alerts reset did not restore the default matcher" + } + & $cn off claude + - name: Test project commands run: | # Create a test project directory diff --git a/README.md b/README.md index 381c291..98e6d77 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ npm packages are published with GitHub Actions Trusted Publisher and npm provena | `cn update check` | Check the latest release and show the update command | | `cn click-through` | Show current macOS click-through mappings | | `cn click-through add ` | Add a macOS click-through mapping | -| `cn alerts` | Configure which events trigger notifications | +| `cn alerts` | Configure which events trigger notifications (Claude Notification types only on native Windows) | | `cn channels` | Configure Slack/Discord delivery channels | | `cn usage` | Configure Codex/Claude usage alerts | | `cn sound on` | Enable sound notifications | @@ -247,6 +247,8 @@ cn alerts reset # Back to default (idle_prompt only) Alert-type matching applies to Claude Code notification hooks and Gemini CLI notification hooks. Changes are applied immediately when Claude notifications are already enabled; otherwise, run `cn on` after choosing the alert types. `ask_user` is a Claude-only `PreToolUse` hook for `AskUserQuestion`. Claude Code agent/team events are separate hook events and are opt-in via `cn alerts add SubagentStop`, `cn alerts add TeammateIdle`, or `cn alerts add TaskCompleted`. +On native Windows, `cn alerts` manages Claude's `idle_prompt`, `permission_prompt`, `auth_success`, and `elicitation_dialog` notification types. Changes apply immediately to enabled global Claude hooks. For an existing project hook, run `cnp on` from that project to apply the new matcher. + Agent-team and subagent workflows can be noisy if `permission_prompt` is enabled. If you only want idle pings, run `cn alerts remove permission_prompt`. Codex currently uses completion events from `notify`, so `permission_prompt` and `idle_prompt` settings do not change Codex behavior. ### Slack And Discord diff --git a/docs/installation.md b/docs/installation.md index e705fd8..25ea25a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -64,6 +64,8 @@ cn channels test all Webhook URLs are stored locally and are redacted in status output. +On native Windows, use `cn alerts` to view Claude notification types. For example, `cn alerts add permission_prompt` adds permission prompts, and `cn alerts reset` restores the default `idle_prompt` matcher. These commands update enabled global Claude hooks immediately. + ### Optional Usage Alerts Usage polling and advance reset reminders support macOS/Linux, including WSL. They are not implemented in the native Windows runtime yet. diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index 013b7a5..f3188c7 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -117,6 +117,8 @@ $script:SettingsFile = if (-not $env:CLAUDE_HOME -and -not (Test-Path $script:De $script:DefaultSettingsFile } $script:NotificationsDir = "$script:ClaudeHome\notifications" +$script:AlertTypesFile = Join-Path $script:NotificationsDir "notify-types" +$script:ClaudeAlertTypes = @("idle_prompt", "permission_prompt", "auth_success", "elicitation_dialog") $script:VoiceFile = "$script:NotificationsDir\voice-enabled" $script:SoundEnabledFile = "$script:NotificationsDir\sound-enabled" $script:SoundCustomFile = "$script:NotificationsDir\sound-custom" @@ -548,6 +550,69 @@ function Get-ClaudeStopCommand { return "powershell -ExecutionPolicy Bypass -File `"$NotifyScript`" stop claude" } +function Get-ClaudeAlertTypes { + if (Test-Path $script:AlertTypesFile) { + $savedTypes = @(([string](Get-Content $script:AlertTypesFile -Raw)) -split '\|' | ForEach-Object { + $_.Trim().ToLowerInvariant() + } | Where-Object { $_ -in $script:ClaudeAlertTypes } | Select-Object -Unique) + if ($savedTypes.Count -gt 0) { + return $savedTypes + } + } + + return "idle_prompt" +} + +function Invoke-ClaudeAlertsCommand { + param([string]$Action, [string]$AlertType) + + $types = @(Get-ClaudeAlertTypes) + if (-not $Action -or $Action -in @("status", "help", "-h", "--help")) { + Write-Host "Claude Alert Types (Windows)" + Write-Host "Matcher: $($types -join '|')" + Write-Host "Available types: $($script:ClaudeAlertTypes -join ', ')" + Write-Host "Usage: cn alerts add | remove | reset" + return + } + + $AlertType = $AlertType.ToLowerInvariant().Replace('-', '_') + if ($Action -in @("add", "remove", "rm") -and $AlertType -notin $script:ClaudeAlertTypes) { + throw "Choose a Claude alert type: $($script:ClaudeAlertTypes -join ', ')" + } + + switch ($Action) { + "add" { + if ($AlertType -notin $types) { + $types += $AlertType + } + } + { $_ -in @("remove", "rm") } { + $types = @($types | Where-Object { $_ -ne $AlertType }) + } + "reset" { $types = @("idle_prompt") } + default { throw "Unknown alerts command: $Action" } + } + if ($types.Count -eq 0) { + $types = @("idle_prompt") + } + + New-Item -ItemType Directory -Path $script:NotificationsDir -Force -ErrorAction Stop | Out-Null + ($types -join '|') | Set-Content $script:AlertTypesFile -Encoding ASCII -ErrorAction Stop + + if (Test-Path $script:SettingsFile) { + $settings = Get-Content $script:SettingsFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $notifyScript = Get-NotifyScript + $stopCommand = Get-ClaudeStopCommand -NotifyScript $notifyScript + if (Test-HookEntriesContainCommand -Entries @($settings.hooks.Stop) -Matcher "" -Command $stopCommand) { + Backup-ConfigFile $script:SettingsFile + $settings = Update-ClaudeSettingsHooks -Settings $settings -NotifyScript $notifyScript + $settings | ConvertTo-Json -Depth 10 | Set-Content $script:SettingsFile -Encoding UTF8 -ErrorAction Stop + } + } + + Write-Success "Claude alert types: $($types -join '|')" +} + function Get-ManagedClaudeNotificationPattern { return '(claude-notify|code-notify.*notifier\.sh|(?:^|[\\/])notify\.(?:ps1|sh)).*(notification|PreToolUse)(?:\s|$)' } @@ -572,7 +637,7 @@ function Test-HookEntriesContainCommand { if ($null -ne $entry.PSObject.Properties['matcher']) { $entryMatcher = [string]$entry.matcher } - if ($entryMatcher -ne $Matcher) { + if ($PSBoundParameters.ContainsKey('Matcher') -and $entryMatcher -ne $Matcher) { continue } @@ -711,7 +776,7 @@ function Update-ClaudeSettingsHooks { if (-not $Disable) { $notificationEntries += [PSCustomObject]@{ - matcher = "idle_prompt" + matcher = (Get-ClaudeAlertTypes) -join '|' hooks = @( [PSCustomObject]@{ type = "command" @@ -768,7 +833,7 @@ function Test-ClaudeSettingsCurrentHooks { $stopCommand = Get-ClaudeStopCommand -NotifyScript $NotifyScript return ( - (Test-HookEntriesContainCommand -Entries @($settings.hooks.Notification) -Matcher "idle_prompt" -Command $notifyCommand) -and + (Test-HookEntriesContainCommand -Entries @($settings.hooks.Notification) -Command $notifyCommand) -and (Test-HookEntriesContainCommand -Entries @($settings.hooks.Stop) -Matcher "" -Command $stopCommand) ) } @@ -1828,6 +1893,7 @@ COMMANDS: test Send a test notification update [check] Update code-notify or check the latest release channels Configure Slack/Discord delivery + alerts [cmd] Show or configure Claude notification types usage Configure Codex/Claude usage alert settings voice on Enable voice notifications voice off Disable voice notifications @@ -1982,6 +2048,9 @@ function Invoke-CodeNotify { "channels" { Invoke-ChannelsCommand -SubCommand ($(if ($SubCommand) { $SubCommand } else { "status" })) -Args $Args } + "alerts" { + Invoke-ClaudeAlertsCommand -Action $SubCommand -AlertType ($Args | Select-Object -First 1) + } "usage" { Invoke-UsageCommand -SubCommand ($(if ($SubCommand) { $SubCommand } else { "status" })) -Args $Args } From 8e71533ea91f7686c2d273f72ff7c515b1748522 Mon Sep 17 00:00:00 2001 From: Kevinlee49 Date: Wed, 11 Nov 2026 08:36:47 -0600 Subject: [PATCH 2/2] fix: keep notification mute state scoped to each tool Addresses #56 by keeping Claude muted when enabling Codex alone. --- lib/code-notify/commands/global.sh | 68 +++++++++++++++++++++--------- lib/code-notify/core/notifier.sh | 7 ++- tests/test-project-kill-switch.sh | 27 ++++++++++++ 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index 8311bec..1c1422f 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -351,7 +351,8 @@ show_version() { # Enable notifications globally enable_notifications_global() { - local tool + local tool other_tool + local mute_file="$HOME/.claude/notifications/disabled" tool="$(normalize_tool_argument "${1:-}")" header "${ROCKET} Enabling Notifications" @@ -359,13 +360,20 @@ enable_notifications_global() { ensure_config_dir - # Remove kill switch if present - rm -f "$HOME/.claude/notifications/disabled" - # If specific tool requested if [[ -n "$tool" ]]; then - enable_single_tool "$tool" - return $? + enable_single_tool "$tool" || return $? + if [[ -f "$mute_file" ]]; then + # Preserve the global mute for the other tools, including running sessions. + for other_tool in claude codex gemini omp; do + if [[ "$other_tool" != "$tool" ]]; then + touch "${mute_file}.$other_tool" || return 1 + fi + done + rm -f "$mute_file" || return 1 + fi + rm -f "${mute_file}.$tool" + return fi # No tool specified - enable for all detected tools @@ -378,9 +386,10 @@ enable_notifications_global() { fi local enabled_count=0 + rm -f "$mute_file" || return 1 for t in $installed_tools; do - if enable_single_tool "$t" "quiet"; then - ((enabled_count++)) + if enable_single_tool "$t" "quiet" && rm -f "${mute_file}.$t"; then + ((++enabled_count)) fi done @@ -454,19 +463,29 @@ enable_single_tool() { # Disable notifications globally disable_notifications_global() { local tool + local mute_file="$HOME/.claude/notifications/disabled" tool="$(normalize_tool_argument "${1:-}")" header "${MUTE} Disabling Notifications" echo "" - # Create kill switch for instant effect on running sessions - touch "$HOME/.claude/notifications/disabled" + mkdir -p "$(dirname "$mute_file")" || return 1 - # If specific tool requested - if [[ -n "$tool" ]]; then - disable_single_tool "$tool" - return $? - fi + # Mute only the requested tool in running sessions. + case "$tool" in + claude|codex|gemini|omp) + touch "${mute_file}.$tool" || return 1 + disable_single_tool "$tool" + return $? + ;; + "") + touch "$mute_file" || return 1 + ;; + *) + error "Unknown tool: $tool" + return 1 + ;; + esac # No tool specified - disable all enabled tools local disabled_count=0 @@ -474,7 +493,7 @@ disable_notifications_global() { for t in claude codex gemini omp; do if is_tool_enabled "$t"; then if disable_single_tool "$t" "quiet"; then - ((disabled_count++)) + ((++disabled_count)) fi fi done @@ -514,6 +533,15 @@ disable_single_tool() { return 0 } +show_configured_tool_status() { + local tool="$1" display_name="$2" + if [[ -f "$HOME/.claude/notifications/disabled.$tool" ]]; then + echo " ${MUTE} $display_name: ${YELLOW}MUTED${RESET}" + else + echo " ${CHECK_MARK} $display_name: ${GREEN}ENABLED${RESET}" + fi +} + # Show current status show_status() { local arg @@ -547,7 +575,7 @@ show_status() { echo " Current hooks still point to an older claude-notify-style configuration" echo " Run: ${CYAN}cn on claude${RESET}" elif is_tool_enabled "claude"; then - echo " ${CHECK_MARK} Claude Code: ${GREEN}ENABLED${RESET}" + show_configured_tool_status claude "Claude Code" echo " Config: $GLOBAL_SETTINGS_FILE" else echo " ${MUTE} Claude Code: ${DIM}DISABLED${RESET}" @@ -559,7 +587,7 @@ show_status() { # Codex if is_tool_installed "codex"; then if is_tool_enabled "codex"; then - echo " ${CHECK_MARK} Codex: ${GREEN}ENABLED${RESET}" + show_configured_tool_status codex Codex echo " Config: $CODEX_CONFIG_FILE" echo " Events: completion-only via Codex notify payloads" else @@ -572,7 +600,7 @@ show_status() { # Gemini CLI if is_tool_installed "gemini"; then if is_tool_enabled "gemini"; then - echo " ${CHECK_MARK} Gemini CLI: ${GREEN}ENABLED${RESET}" + show_configured_tool_status gemini "Gemini CLI" echo " Config: $GEMINI_SETTINGS_FILE" else echo " ${MUTE} Gemini CLI: ${DIM}DISABLED${RESET}" @@ -584,7 +612,7 @@ show_status() { # Oh My Pi (omp) if is_tool_installed "omp"; then if is_tool_enabled "omp"; then - echo " ${CHECK_MARK} omp: ${GREEN}ENABLED${RESET}" + show_configured_tool_status omp omp echo " Config: $OMP_EXTENSION_FILE" echo " Events: completion via agent_end extension" else diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 71a2990..26fe13c 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -399,8 +399,11 @@ is_codex_desktop_trigger() { # Function to check if notification should be suppressed should_suppress_notification() { # Check kill switch first - instant disable without restart - if [[ -f "$HOME/.claude/notifications/disabled" ]] && ! is_project_scoped_notification; then - return 0 # Suppress notification + if ! is_project_scoped_notification; then + local mute_tool="${TOOL_NAME:-claude}" + if [[ -f "$NOTIFICATIONS_DIR/disabled" || -f "$NOTIFICATIONS_DIR/disabled.$(sanitize_rate_limit_key "$mute_tool")" ]]; then + return 0 + fi fi # Skip suppression checks for test notifications diff --git a/tests/test-project-kill-switch.sh b/tests/test-project-kill-switch.sh index e6cc1e0..92bcf94 100644 --- a/tests/test-project-kill-switch.sh +++ b/tests/test-project-kill-switch.sh @@ -4,6 +4,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" NOTIFIER="$SCRIPT_DIR/../lib/code-notify/core/notifier.sh" +NPM_CN="$SCRIPT_DIR/../bin/npm-cn.js" pass() { echo "PASS: $1"; } fail() { echo "FAIL: $1"; exit 1; } @@ -26,6 +27,9 @@ test_dir="$(mktemp -d)" trap 'rm -rf "$test_dir"' EXIT export HOME="$test_dir/home" +export CLAUDE_HOME="$HOME/.claude" +export CLAUDE_SETTINGS_HOME="$CLAUDE_HOME" +export CODEX_HOME="$HOME/.codex" fake_bin="$test_dir/bin" log_dir="$test_dir/log" mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$log_dir" @@ -67,3 +71,26 @@ wait_for_lines "$notification_log" 1 || fail "project-scoped notification should grep -q "demo-project" "$notification_log" || fail "project-scoped notification did not include the project name" pass "project-scoped hooks bypass the global kill switch while global hooks stay muted" + +if command -v node >/dev/null 2>&1; then + ln -s /usr/bin/true "$fake_bin/codex" + delivery_log="$HOME/.claude/logs/notifications.log" + + # Enable Codex while the existing all-tools mute is active. + PATH="$fake_bin:$PATH" node "$NPM_CN" on codex >/dev/null + [[ -f "$HOME/.codex/config.toml" ]] || fail "cn on codex did not configure Codex" + [[ ! -f "$HOME/.claude/settings.json" ]] || fail "cn on codex wrote Claude settings" + PATH="$fake_path" bash "$NOTIFIER" stop claude + PATH="$fake_path" bash "$NOTIFIER" codex '{"type":"agent-turn-complete"}' + [[ $(wc -l < "$delivery_log") -eq 2 ]] || fail "cn on codex should only unmute Codex" + tail -1 "$delivery_log" | grep -q '\[codex\]' || fail "Codex stayed muted after cn on codex" + + # Once Claude is enabled, toggling Codex must leave Claude delivery active. + PATH="$fake_bin:$PATH" node "$NPM_CN" on claude >/dev/null + PATH="$fake_bin:$PATH" node "$NPM_CN" off codex >/dev/null + PATH="$fake_path" CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 bash "$NOTIFIER" codex '{"type":"agent-turn-complete"}' + PATH="$fake_path" CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 bash "$NOTIFIER" stop claude + [[ $(wc -l < "$delivery_log") -eq 3 ]] || fail "cn off codex should only mute Codex" + tail -1 "$delivery_log" | grep -q '\[claude\]' || fail "cn off codex muted Claude" + pass "npm cn on and off keep Codex and Claude notification mutes independent" +fi