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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions hook/_lib.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion hook/_lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
4 changes: 3 additions & 1 deletion hook/claude-notifier-on-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions hook/claude-notifier-on-notification.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
4 changes: 3 additions & 1 deletion hook/claude-notifier-on-permission.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions hook/claude-notifier-on-permission.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions hook/claude-notifier-on-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
// 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");

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);
Expand Down
2 changes: 2 additions & 0 deletions hook/claude-notifier-on-prompt.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion hook/claude-notifier-on-question.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions hook/claude-notifier-on-question.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
4 changes: 3 additions & 1 deletion hook/claude-notifier-on-stop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions hook/claude-notifier-on-stop.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
4 changes: 3 additions & 1 deletion hook/claude-notifier-on-subagent-stop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions hook/claude-notifier-on-subagent-stop.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
29 changes: 29 additions & 0 deletions test/hook/lib.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 50 additions & 0 deletions test/hook/on-disable.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof tmpHome>;
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("");
});
}
});