diff --git a/CHANGELOG.md b/CHANGELOG.md index a523932..6a38a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [3.4.0] - 2026-06-21 + +### Added + +- **`CLAUDE_NOTIFIER_DISABLE` environment variable** for per-session opt-out. Setting it (to any value other than empty/`0`/`false`) in a shell makes every hook exit silently — no sound, popup, or signal — for sessions in that shell only, leaving other sessions and the machine-wide mute flag untouched. Intended for shared SSH hosts, where the host's hooks otherwise play sounds for every user's sessions. ([#63](https://github.com/ashmitb95/claude-notifier/issues/63)) + ## [3.3.2] - 2026-06-05 ### Fixed diff --git a/README.md b/README.md index d276ef8..91c5dcb 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,18 @@ New-Item "$env:USERPROFILE\.claude\hooks\claude-notifier-muted" # mute Remove-Item "$env:USERPROFILE\.claude\hooks\claude-notifier-muted" # unmute ``` +## Disable per session (`CLAUDE_NOTIFIER_DISABLE`) + +The mute flag above is machine-wide. To silence the hooks for a **single +session only** — e.g. when SSHing into a shared host so your sessions don't +play sounds on someone else's machine — set `CLAUDE_NOTIFIER_DISABLE` in that +shell. When set (to any value other than empty/`0`/`false`), every hook exits +without sound, popup, or signal; sessions in other shells are unaffected. + +```sh +export CLAUDE_NOTIFIER_DISABLE=1 # add to your shell rc to make it permanent +``` + ## Platform support | Platform | VSCode Extension | CLI Install | Hook runner | diff --git a/hook/_lib.ps1 b/hook/_lib.ps1 index a9a8c96..b513e5d 100644 --- a/hook/_lib.ps1 +++ b/hook/_lib.ps1 @@ -47,6 +47,18 @@ function Test-NotifierMuted() { return (Test-Path $LibMuteFlag) } +# Per-session opt-out: set CLAUDE_NOTIFIER_DISABLE in the shell to silence all +# hook output (sound, popup, and signal) for that session only. Unlike the +# machine-wide mute flag, this is scoped to the process environment, so a user +# on a shared host can disable just their own sessions. Any non-empty value +# other than "0"/"false" counts as disabled. +function Test-NotifierDisabled() { + $v = $env:CLAUDE_NOTIFIER_DISABLE + if (-not $v) { return $false } + if ($v -eq '0' -or $v.ToLower() -eq 'false') { return $false } + return $true +} + # Play a sound file synchronously. Falls back to $Fallback if $Path doesn't # exist (e.g. user picked a sound that isn't installed); beeps if neither # exists. Silently swallows errors — sound failure should never break a hook. diff --git a/hook/_lib/config.js b/hook/_lib/config.js index 1ab6c5f..55d06f1 100644 --- a/hook/_lib/config.js +++ b/hook/_lib/config.js @@ -13,4 +13,14 @@ function isMuted() { return fs.existsSync(MUTE_FLAG); } -module.exports = { readConfig, isMuted }; +// Per-session opt-out: set CLAUDE_NOTIFIER_DISABLE in the shell to silence all +// hook output (sound, popup, and signal) for that session only. Unlike the +// machine-wide mute flag, this is scoped to the process environment, so an SSH +// user on a shared host can disable just their own sessions. Any non-empty +// value other than "0"/"false" counts as disabled. +function isDisabled() { + const v = process.env.CLAUDE_NOTIFIER_DISABLE; + return !!v && v !== "0" && v.toLowerCase() !== "false"; +} + +module.exports = { readConfig, isMuted, isDisabled }; diff --git a/hook/claude-notifier-on-notification.js b/hook/claude-notifier-on-notification.js index 619c537..95336d2 100644 --- a/hook/claude-notifier-on-notification.js +++ b/hook/claude-notifier-on-notification.js @@ -3,7 +3,7 @@ // Plays the "needs input" sound when Claude posts a permission_prompt // notification. Uses fixed sound (not config-driven) — this hook fires // before the PermissionRequest hook can react. -const { isMuted, readConfig } = require("./_lib/config"); +const { isMuted, isDisabled, readConfig } = require("./_lib/config"); const { resolveSound, BUNDLED_FALLBACK } = require("./_lib/sounds"); const { playSound } = require("./_lib/play"); const { showNotification } = require("./_lib/notify"); @@ -13,6 +13,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-notification.ps1 b/hook/claude-notifier-on-notification.ps1 index febac31..6f2e2ad 100644 --- a/hook/claude-notifier-on-notification.ps1 +++ b/hook/claude-notifier-on-notification.ps1 @@ -7,6 +7,7 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } if ($data.notification_type -ne 'permission_prompt') { exit 0 } if (Test-NotifierMuted) { exit 0 } diff --git a/hook/claude-notifier-on-permission.js b/hook/claude-notifier-on-permission.js index 09faed9..4f2d036 100644 --- a/hook/claude-notifier-on-permission.js +++ b/hook/claude-notifier-on-permission.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // Claude Notifier — PermissionRequest hook // Plays a sound + shows a notification when Claude needs permission to use a tool. -const { isMuted, readConfig } = require("./_lib/config"); +const { isMuted, isDisabled, readConfig } = require("./_lib/config"); const { resolveSound, BUNDLED_FALLBACK } = require("./_lib/sounds"); const { playSound } = require("./_lib/play"); const { showNotification } = require("./_lib/notify"); @@ -13,6 +13,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-permission.ps1 b/hook/claude-notifier-on-permission.ps1 index 4833089..396d704 100644 --- a/hook/claude-notifier-on-permission.ps1 +++ b/hook/claude-notifier-on-permission.ps1 @@ -6,6 +6,7 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } if (Test-NotifierMuted) { exit 0 } # AskUserQuestion is handled by the separate PreToolUse question hook. diff --git a/hook/claude-notifier-on-prompt.js b/hook/claude-notifier-on-prompt.js index f84d64f..e0976ca 100644 --- a/hook/claude-notifier-on-prompt.js +++ b/hook/claude-notifier-on-prompt.js @@ -4,6 +4,7 @@ // submits a new prompt, and records the prompt-submit timestamp into a // per-session marker file so threshold-aware sound paths can suppress // short-task notifications. No sound, no notification — coordination only. +const { isDisabled } = require("./_lib/config"); const { writeSignal } = require("./_lib/signal"); const { recordTaskStart } = require("./_lib/task-timer"); @@ -11,6 +12,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-prompt.ps1 b/hook/claude-notifier-on-prompt.ps1 index 6e3a9b2..2738511 100644 --- a/hook/claude-notifier-on-prompt.ps1 +++ b/hook/claude-notifier-on-prompt.ps1 @@ -7,6 +7,8 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } + Write-NotifierSignal -Reason 'prompt' -SessionId $data.session_id Save-NotifierTaskStart -SessionId $data.session_id diff --git a/hook/claude-notifier-on-question.js b/hook/claude-notifier-on-question.js index ae54011..c87ffc1 100644 --- a/hook/claude-notifier-on-question.js +++ b/hook/claude-notifier-on-question.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // Claude Notifier — PreToolUse hook for AskUserQuestion // Plays a sound + shows a notification when Claude asks the user a question. -const { isMuted, readConfig } = require("./_lib/config"); +const { isMuted, isDisabled, readConfig } = require("./_lib/config"); const { resolveSound, BUNDLED_FALLBACK } = require("./_lib/sounds"); const { playSound } = require("./_lib/play"); const { showNotification } = require("./_lib/notify"); @@ -13,6 +13,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-question.ps1 b/hook/claude-notifier-on-question.ps1 index 7940fc3..1bc3f4c 100644 --- a/hook/claude-notifier-on-question.ps1 +++ b/hook/claude-notifier-on-question.ps1 @@ -6,6 +6,8 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } + # Defense-in-depth: bail if a misconfigured matcher routes other tools here. if ($data.tool_name -ne 'AskUserQuestion') { exit 0 } diff --git a/hook/claude-notifier-on-stop.js b/hook/claude-notifier-on-stop.js index 4e14196..4b4014e 100644 --- a/hook/claude-notifier-on-stop.js +++ b/hook/claude-notifier-on-stop.js @@ -3,7 +3,7 @@ // Writes a "done" signal for the VSCode extension to debounce. When no // extension is active (terminal-only Claude session, or session outside any // open workspace), plays sound/notification directly as a fallback. -const { isMuted, readConfig } = require("./_lib/config"); +const { isMuted, isDisabled, readConfig } = require("./_lib/config"); const { resolveSound, BUNDLED_FALLBACK } = require("./_lib/sounds"); const { playSound } = require("./_lib/play"); const { showNotification } = require("./_lib/notify"); @@ -17,6 +17,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-stop.ps1 b/hook/claude-notifier-on-stop.ps1 index d251136..7ff5bcf 100644 --- a/hook/claude-notifier-on-stop.ps1 +++ b/hook/claude-notifier-on-stop.ps1 @@ -7,6 +7,7 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } if ($data.stop_hook_active) { exit 0 } if (Test-NotifierMuted) { exit 0 } diff --git a/hook/claude-notifier-on-subagent-stop.js b/hook/claude-notifier-on-subagent-stop.js index a5e5a39..439acdf 100644 --- a/hook/claude-notifier-on-subagent-stop.js +++ b/hook/claude-notifier-on-subagent-stop.js @@ -4,7 +4,7 @@ // silent until the user opts in via claudeNotifier.subagentCompleted.level. // Mirrors the Stop hook's split: when a VS Code window owns the cwd, the // extension handles the sound + popup; otherwise the hook plays directly. -const { isMuted, readConfig } = require("./_lib/config"); +const { isMuted, isDisabled, readConfig } = require("./_lib/config"); const { resolveSound, BUNDLED_FALLBACK } = require("./_lib/sounds"); const { playSound } = require("./_lib/play"); const { showNotification } = require("./_lib/notify"); @@ -16,6 +16,8 @@ let raw = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => { + if (isDisabled()) process.exit(0); + let input = {}; try { input = JSON.parse(raw); diff --git a/hook/claude-notifier-on-subagent-stop.ps1 b/hook/claude-notifier-on-subagent-stop.ps1 index 19d4d89..24ea153 100644 --- a/hook/claude-notifier-on-subagent-stop.ps1 +++ b/hook/claude-notifier-on-subagent-stop.ps1 @@ -7,6 +7,7 @@ $ErrorActionPreference = 'SilentlyContinue' $raw = [Console]::In.ReadToEnd() try { $data = $raw | ConvertFrom-Json } catch { exit 0 } +if (Test-NotifierDisabled) { exit 0 } if (Test-NotifierMuted) { exit 0 } $cwd = "" diff --git a/package-lock.json b/package-lock.json index 0eeff70..e7eb4d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-notifier", - "version": "3.3.2", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-notifier", - "version": "3.3.2", + "version": "3.4.0", "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^20.0.0", diff --git a/package.json b/package.json index 3f57e1b..88aa0d6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-notifier", "displayName": "Claude Notifier", "description": "Plays distinct sounds when Claude Code finishes a task or needs your input", - "version": "3.3.2", + "version": "3.4.0", "icon": "media/icon.png", "publisher": "SingularityInc", "repository": { diff --git a/test/hook/lib.config.test.ts b/test/hook/lib.config.test.ts index 712ee1d..391cc5b 100644 --- a/test/hook/lib.config.test.ts +++ b/test/hook/lib.config.test.ts @@ -45,6 +45,35 @@ describe("hook/_lib/config — isMuted", () => { }); }); +describe("hook/_lib/config — isDisabled", () => { + const ORIG = process.env.CLAUDE_NOTIFIER_DISABLE; + afterAll(() => { + if (ORIG === undefined) delete process.env.CLAUDE_NOTIFIER_DISABLE; + else process.env.CLAUDE_NOTIFIER_DISABLE = ORIG; + }); + beforeEach(() => { + delete process.env.CLAUDE_NOTIFIER_DISABLE; + }); + + it("false when env var is unset", () => { + expect(config.isDisabled()).toBe(false); + }); + + it("false for empty, '0', and 'false' (case-insensitive)", () => { + for (const v of ["", "0", "false", "False", "FALSE"]) { + process.env.CLAUDE_NOTIFIER_DISABLE = v; + expect(config.isDisabled()).toBe(false); + } + }); + + it("true for any other non-empty value", () => { + for (const v of ["1", "true", "yes", "on"]) { + process.env.CLAUDE_NOTIFIER_DISABLE = v; + expect(config.isDisabled()).toBe(true); + } + }); +}); + describe("hook/_lib/config — readConfig", () => { it("returns null when config file does not exist", () => { expect(config.readConfig()).toBeNull(); diff --git a/test/hook/on-disable.test.ts b/test/hook/on-disable.test.ts new file mode 100644 index 0000000..8dd25fd --- /dev/null +++ b/test/hook/on-disable.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { tmpHome, runHook, readSignal, hookScript } from "./_helpers"; + +// CLAUDE_NOTIFIER_DISABLE is a per-session opt-out: when set, every hook must +// exit silently without writing a signal, playing sound, or showing a popup — +// even on the terminal-fallback path (no active extension marker is created +// here, so the hooks would otherwise fall through to direct playback). +const CASES = [ + { name: "claude-notifier-on-stop", stdin: { session_id: "s-1", cwd: "/tmp/x" } }, + { name: "claude-notifier-on-subagent-stop", stdin: { session_id: "s-1", cwd: "/tmp/x" } }, + { name: "claude-notifier-on-prompt", stdin: { session_id: "s-1" } }, + { + name: "claude-notifier-on-permission", + stdin: { session_id: "s-1", tool_name: "Bash", cwd: "/tmp/x" }, + }, + { + name: "claude-notifier-on-question", + stdin: { session_id: "s-1", tool_name: "AskUserQuestion", cwd: "/tmp/x" }, + }, + { + name: "claude-notifier-on-notification", + stdin: { session_id: "s-1", notification_type: "permission_prompt" }, + }, +]; + +describe("hooks: CLAUDE_NOTIFIER_DISABLE", () => { + let home: ReturnType; + beforeEach(() => (home = tmpHome())); + afterEach(() => home.dispose()); + + for (const c of CASES) { + it(`${c.name}: exits 0 with no signal when disabled`, () => { + const res = runHook(hookScript(c.name), c.stdin, home.root, { + extraEnv: { CLAUDE_NOTIFIER_DISABLE: "1" }, + }); + expect(res.status).toBe(0); + expect(readSignal(home.signalFile)).toBe(""); + // Fast exit confirms no sound spawn was attempted. + expect(res.durationMs).toBeLessThan(500); + }); + + it(`${c.name}: CLAUDE_NOTIFIER_DISABLE=0 does not suppress (signal still written)`, () => { + const res = runHook(hookScript(c.name), c.stdin, home.root, { + extraEnv: { CLAUDE_NOTIFIER_DISABLE: "0" }, + }); + expect(res.status).toBe(0); + expect(readSignal(home.signalFile)).not.toBe(""); + }); + } +});