Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/windows-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>` | 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 |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 48 additions & 20 deletions lib/code-notify/commands/global.sh
Original file line number Diff line number Diff line change
Expand Up @@ -351,21 +351,29 @@ 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"
echo ""

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
Expand All @@ -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

Expand Down Expand Up @@ -454,27 +463,37 @@ 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

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand All @@ -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
Expand All @@ -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}"
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions lib/code-notify/core/notifier.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 72 additions & 3 deletions scripts/install-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <type> | remove <type> | 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|$)'
}
Expand All @@ -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
}

Expand Down Expand Up @@ -711,7 +776,7 @@ function Update-ClaudeSettingsHooks {

if (-not $Disable) {
$notificationEntries += [PSCustomObject]@{
matcher = "idle_prompt"
matcher = (Get-ClaudeAlertTypes) -join '|'
hooks = @(
[PSCustomObject]@{
type = "command"
Expand Down Expand Up @@ -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)
)
}
Expand Down Expand Up @@ -1828,6 +1893,7 @@ COMMANDS:
test Send a test notification
update [check] Update code-notify or check the latest release
channels <cmd> Configure Slack/Discord delivery
alerts [cmd] Show or configure Claude notification types
usage <cmd> Configure Codex/Claude usage alert settings
voice on Enable voice notifications
voice off Disable voice notifications
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions tests/test-project-kill-switch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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"
Expand Down Expand Up @@ -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
Loading