Skip to content

Commit b0fa45e

Browse files
virtualianclaude
andauthored
feat: Add granular notification control with per-channel toggles (#27) (#29)
Add per-channel notification control via /notification skill and VoiceGate hook enhancement. Voice and desktop channels can be independently toggled on/off through settings.json, with VoiceGate blocking all curl commands when voice is disabled. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 941f796 commit b0fa45e

4 files changed

Lines changed: 227 additions & 12 deletions

File tree

Releases/v3.0/.claude/hooks/VoiceGate.hook.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
#!/usr/bin/env bun
22
/**
3-
* VoiceGate.hook.ts - Block Voice Curls from Subagents (PreToolUse)
3+
* VoiceGate.hook.ts - Voice Notification Gate (PreToolUse)
44
*
55
* PURPOSE:
6-
* Prevents background agents / subagents from sending voice notifications.
7-
* Only the main terminal session (identified by having a kitty-sessions file)
8-
* is allowed to curl the voice server at localhost:8888.
6+
* Controls voice notification delivery via two mechanisms:
7+
* 1. Per-channel toggle: When notifications.voice is false in settings.json,
8+
* ALL voice curls are blocked — zero noise, zero execution.
9+
* 2. Subagent blocking: When voice is enabled, only the main terminal session
10+
* can send voice curls. Subagents are silently blocked.
911
*
1012
* ROOT CAUSE THIS FIXES:
11-
* Subagents inherit full PAI context (CLAUDE.md → SKILL.md → Algorithm),
12-
* which mandates voice curls at every phase. Without this gate, every
13-
* spawned agent triggers voice announcements — flooding the voice server.
13+
* - Issue #27: No granular notification control — curls fire unconditionally
14+
* - Subagent flooding: Spawned agents inherit Algorithm context and fire curls
1415
*
1516
* TRIGGER: PreToolUse (matcher: Bash)
1617
*
1718
* DECISION LOGIC:
1819
* 1. Command doesn't contain "localhost:8888" → PASS (not a voice curl)
19-
* 2. Command contains "localhost:8888" AND is main session → PASS
20-
* 3. Command contains "localhost:8888" AND is NOT main session → BLOCK
20+
* 2. notifications.voice is false in settings.json → BLOCK (user disabled voice)
21+
* 3. Command contains "localhost:8888" AND is main session → PASS
22+
* 4. Command contains "localhost:8888" AND is NOT main session → BLOCK
2123
*
22-
* PERFORMANCE: <5ms. Fast-path exit for non-voice commands.
24+
* PERFORMANCE: <5ms. Fast-path exit for non-voice commands. Settings read
25+
* is synchronous file I/O but only triggered for voice curls (~7 per session).
2326
*/
2427

25-
import { existsSync } from 'fs';
28+
import { existsSync, readFileSync } from 'fs';
2629
import { join } from 'path';
2730
import { homedir } from 'os';
2831

@@ -34,6 +37,27 @@ interface HookInput {
3437
session_id: string;
3538
}
3639

40+
function isVoiceEnabled(): boolean {
41+
// Read notifications.voice from settings.json
42+
// Supports two formats:
43+
// Boolean: { "voice": true }
44+
// Object: { "voice": { "enabled": true } }
45+
// Default to true (voice enabled) if setting is missing or unreadable
46+
const paiDir = process.env.PAI_DIR || join(homedir(), '.claude');
47+
const settingsPath = join(paiDir, 'settings.json');
48+
try {
49+
if (!existsSync(settingsPath)) return true;
50+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
51+
const voice = settings?.notifications?.voice;
52+
if (voice === undefined || voice === null) return true; // Missing → enabled
53+
if (typeof voice === 'boolean') return voice; // Boolean format
54+
if (typeof voice === 'object') return voice.enabled !== false; // Object format
55+
return true; // Unknown format → default enabled
56+
} catch {
57+
return true; // Can't read settings → default to enabled
58+
}
59+
}
60+
3761
function isMainSession(sessionId: string): boolean {
3862
// Terminal detection: if we're in a recognized terminal, this is a main session.
3963
// Subagents spawned by Task tool don't inherit terminal environment variables,
@@ -83,7 +107,16 @@ async function main() {
83107
return;
84108
}
85109

86-
// It's a voice curl — check if main session
110+
// It's a voice curl — check if voice notifications are enabled
111+
if (!isVoiceEnabled()) {
112+
console.log(JSON.stringify({
113+
decision: "block",
114+
reason: "Voice notifications are disabled (notifications.voice = false in settings.json). Use /notification voice on to re-enable."
115+
}));
116+
return;
117+
}
118+
119+
// Voice is enabled — check if main session
87120
if (isMainSession(input.session_id)) {
88121
console.log(JSON.stringify({ continue: true }));
89122
return;

Releases/v3.0/.claude/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,12 @@
802802
},
803803
"max_tokens": 16000,
804804
"notifications": {
805+
"voice": {
806+
"enabled": true
807+
},
808+
"desktop": {
809+
"enabled": true
810+
},
805811
"ntfy": {
806812
"enabled": true,
807813
"topic": "${NTFY_TOPIC}",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
---
2+
name: Notification
3+
description: Per-channel notification control. USE WHEN notification, notifications, voice on, voice off, mute, unmute, notification status.
4+
---
5+
6+
## Customization
7+
8+
**Before executing, check for user customizations at:**
9+
`~/.claude/skills/PAI/USER/SKILLCUSTOMIZATIONS/Notification/`
10+
11+
If this directory exists, load and apply any PREFERENCES.md, configurations, or resources found there. These override default behavior. If the directory does not exist, proceed with skill defaults.
12+
13+
---
14+
15+
## Slash Command Interface
16+
17+
This skill implements `/notification` with per-channel toggle control:
18+
19+
```
20+
/notification voice on # Enable voice (curl) notifications
21+
/notification voice off # Disable voice notifications — suppresses ALL curl calls
22+
/notification desktop on # Enable desktop notifications
23+
/notification desktop off # Disable desktop notifications
24+
/notification status # Show current notification channel states
25+
/notification all off # Disable all notification channels
26+
/notification all on # Enable all notification channels
27+
```
28+
29+
---
30+
31+
## Workflow Routing
32+
33+
**When user requests toggling voice notifications:**
34+
Examples: "/notification voice on", "/notification voice off", "turn off voice", "mute voice", "unmute voice", "disable voice notifications", "enable voice", "silence the curls", "stop voice announcements"
35+
-> **Action:** Read `~/.claude/settings.json`, set `notifications.voice` to `true` or `false`, write back. Confirm the change.
36+
-> **Feedback:** "Voice notifications [enabled/disabled]. Phase announcement curls will [fire normally/be suppressed]."
37+
38+
**When user requests toggling desktop notifications:**
39+
Examples: "/notification desktop on", "/notification desktop off", "turn off desktop", "mute desktop", "disable desktop notifications"
40+
-> **Action:** Read `~/.claude/settings.json`, set `notifications.desktop` to `true` or `false`, write back. Confirm the change.
41+
-> **Feedback:** "Desktop notifications [enabled/disabled]."
42+
43+
**When user requests notification status:**
44+
Examples: "/notification status", "notification status", "what notifications are on", "show notification settings"
45+
-> **Action:** Read `~/.claude/settings.json` and display current state of all notification channels.
46+
-> **Output format:**
47+
```
48+
Notification Channel Status:
49+
Voice: [ON/OFF] — Phase announcement curls
50+
Desktop: [ON/OFF] — Desktop alerts
51+
ntfy: [ON/OFF] — Push notifications (mobile)
52+
Discord: [ON/OFF] — Discord webhook
53+
```
54+
55+
**When user requests toggling all notifications:**
56+
Examples: "/notification all off", "/notification all on", "mute all", "unmute all", "silence everything", "turn on all notifications"
57+
-> **Action:** Read `~/.claude/settings.json`, set `notifications.voice` and `notifications.desktop` to the requested state, write back. Confirm the change.
58+
-> **Feedback:** "All notification channels [enabled/disabled]."
59+
60+
---
61+
62+
## Settings Location
63+
64+
Notification state is stored in `~/.claude/settings.json` under the `notifications` key:
65+
66+
```json
67+
{
68+
"notifications": {
69+
"voice": { "enabled": true },
70+
"desktop": { "enabled": true },
71+
"ntfy": { "enabled": true, ... },
72+
"discord": { "enabled": true, ... },
73+
...
74+
}
75+
}
76+
```
77+
78+
- `voice.enabled` (boolean): Controls phase announcement curl commands. When `false`, VoiceGate.hook.ts blocks all curls to localhost:8888.
79+
- `desktop.enabled` (boolean): Controls desktop notification delivery.
80+
81+
Both boolean format (`"voice": true`) and object format (`"voice": { "enabled": true }`) are supported for backwards compatibility.
82+
83+
Settings persist across sessions automatically since they live in settings.json.
84+
85+
---
86+
87+
## Implementation Details
88+
89+
### Voice Suppression Mechanism
90+
91+
The voice toggle works through `VoiceGate.hook.ts` (PreToolUse hook on Bash):
92+
93+
1. Algorithm emits a `curl -s -X POST http://localhost:8888/notify ...` command
94+
2. VoiceGate intercepts it before execution
95+
3. If `notifications.voice` is `false` in settings.json -> **BLOCK** (curl never executes)
96+
4. If `notifications.voice` is `true` (or missing) -> proceed to subagent check
97+
5. If main session -> **PASS** (curl executes)
98+
6. If subagent -> **BLOCK** (subagent curls always blocked)
99+
100+
This means the Algorithm template's `[VERBATIM]` curl directives remain unchanged. The gating happens at infrastructure level, not prompt level.
101+
102+
### Desktop Suppression
103+
104+
Desktop notifications are sent via macOS `osascript` or notification hooks. When `notifications.desktop` is `false`, notification-sending code should check this setting before firing.
105+
106+
---
107+
108+
## When to Activate This Skill
109+
110+
### Direct Notification Commands
111+
- "/notification voice on/off"
112+
- "/notification desktop on/off"
113+
- "/notification status"
114+
- "/notification all on/off"
115+
116+
### Natural Language Triggers
117+
- "turn off voice notifications"
118+
- "mute the voice", "silence curls"
119+
- "stop announcing phases"
120+
- "enable voice", "unmute"
121+
- "what notification channels are on"
122+
- "show notification settings"
123+
- "disable all notifications"
124+
125+
---
126+
127+
## Related Documentation
128+
129+
- **Notification System:** `~/.claude/skills/PAI/THENOTIFICATIONSYSTEM.md`
130+
- **VoiceGate Hook:** `~/.claude/hooks/VoiceGate.hook.ts`
131+
- **Settings:** `~/.claude/settings.json``notifications` section

Releases/v3.0/.claude/skills/PAI/THENOTIFICATIONSYSTEM.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,51 @@
55
This system provides:
66
- Voice feedback when workflows start
77
- Consistent user experience across all skills
8+
- **Per-channel notification control** via `/notification` skill
9+
10+
---
11+
12+
## Per-Channel Notification Control
13+
14+
**Granular toggle control over notification channels.** Use the `/notification` skill:
15+
16+
```
17+
/notification voice on # Enable voice (curl) notifications
18+
/notification voice off # Disable voice notifications — suppresses ALL curl calls
19+
/notification desktop on # Enable desktop notifications
20+
/notification desktop off # Disable desktop notifications
21+
/notification status # Show current notification channel states
22+
/notification all off # Disable all notification channels
23+
/notification all on # Enable all notification channels
24+
```
25+
26+
### Settings Persistence
27+
28+
Notification state is stored in `settings.json`:
29+
30+
```json
31+
{
32+
"notifications": {
33+
"voice": { "enabled": true },
34+
"desktop": { "enabled": true }
35+
}
36+
}
37+
```
38+
39+
Both boolean (`"voice": false`) and object (`"voice": { "enabled": false }`) formats are supported.
40+
41+
### Voice Curl Suppression
42+
43+
When `notifications.voice` is `false`, the `VoiceGate.hook.ts` PreToolUse hook blocks ALL `curl` commands targeting `localhost:8888` before they execute. This means:
44+
45+
- **Zero terminal noise** — no curl commands appear in output
46+
- **Zero execution** — the voice server is never contacted
47+
- **Zero changes to Algorithm template** — curls remain `[VERBATIM]` in the prompt; gating happens at hook level
48+
- **Background agents inherit the setting** — VoiceGate reads from `settings.json` which is shared across all sessions
49+
50+
### Skill Reference
51+
52+
Full documentation: `~/.claude/skills/Notification/SKILL.md`
853

954
---
1055

0 commit comments

Comments
 (0)