Skip to content

Commit 406465c

Browse files
authored
Merge pull request #66 from axiomantic/elijahr/system-notifications
feat: add system notification support to MCP server
2 parents 1a28253 + 633ecac commit 406465c

16 files changed

Lines changed: 2044 additions & 17 deletions

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.19.1
1+
0.20.0

AGENTS.spellbook.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ Spellbook can announce long-running tool completions via Kokoro text-to-speech.
151151

152152
**Auto-notifications:** PreToolUse hook records start times; PostToolUse hook announces completions exceeding 30 seconds. Threshold configurable via `SPELLBOOK_TTS_THRESHOLD`. Interactive and management tools (AskUserQuestion, TodoRead, TodoWrite, TaskCreate, TaskUpdate, TaskGet, TaskList) are excluded.
153153

154+
## Notification Configuration
155+
156+
Spellbook can send native OS notifications when long-running tools finish. Uses macOS Notification Center, Linux notify-send, or Windows toast notifications.
157+
158+
**Available MCP tools:**
159+
- `notify_send(body, title?)` - Send a notification manually
160+
- `notify_status()` - Check notification availability and settings
161+
- `notify_session_set(enabled?, title?)` - Override settings for this session
162+
- `notify_config_set(enabled?, title?)` - Change persistent settings
163+
164+
**Quick commands:**
165+
- Mute this session: call `notify_session_set(enabled=false)`
166+
- Unmute this session: call `notify_session_set(enabled=true)`
167+
- Change title: call `notify_config_set(title="My Project")`
168+
169+
**Auto-notifications:** PostToolUse hook sends a notification when tools exceed the threshold (default 30 seconds). Set threshold via `SPELLBOOK_NOTIFY_THRESHOLD` env var.
170+
171+
**Scope note:** `notify_session_set` and `notify_config_set` only affect MCP tool behavior (e.g., `notify_send` respects enabled state). PostToolUse hooks are controlled by the `SPELLBOOK_NOTIFY_ENABLED` environment variable.
172+
154173
## Encyclopedia
155174

156175
**Contents:** Glossary, architecture skeleton (mermaid), decision log (why X not Y), entry points, testing commands. Overview-only design resists staleness.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.20.0] - 2026-03-05
11+
12+
### Added
13+
- **System notification support** (`spellbook_mcp/notify.py`) - Native OS notifications as a visual/accessibility alternative to TTS. Fires macOS Notification Center, Linux `notify-send`, or Windows PowerShell toast when tools exceed a configurable threshold (default 30s). Platform detection handles containers, SSH/headless sessions, and Wayland desktops.
14+
- **Notification MCP tools** - `notify_send`, `notify_status`, `notify_session_set`, `notify_config_set` for manual notifications, availability checking, and per-session or persistent configuration.
15+
- **PostToolUse notification hooks** (`hooks/notify-on-complete.{sh,py}`) - Separate hooks that call platform notification tools directly (no HTTP round-trip, unlike TTS). Independent lifecycle, threshold, and blacklist from TTS hooks.
16+
- **Dual PreToolUse timer files** - `tts-timer-start` hooks now write both `/tmp/claude-tool-start-{id}` (TTS) and `/tmp/claude-notify-start-{id}` (notifications), eliminating race conditions between async hooks.
17+
- **Notification configuration** in `config_tools.py` - Session state (`notify` key), persistent config (`notify_enabled`, `notify_title`), and `SPELLBOOK_NOTIFY_THRESHOLD` / `SPELLBOOK_NOTIFY_ENABLED` environment variables for hook control.
18+
- **Notification test suite** - 76 tests across 4 files: core module, config integration, MCP tools, and hook behavior.
19+
1020
## [0.19.0] - 2026-03-04
1121

1222
### Added

hooks/notify-on-complete.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""PostToolUse hook: Send OS notification when tools exceed threshold.
3+
4+
Reads and deletes /tmp/claude-notify-start-{tool_use_id} (or %TEMP% on Windows).
5+
Calls platform notification tools directly -- no HTTP round-trip.
6+
7+
On non-Windows: delegates to notify-on-complete.sh via os.execv.
8+
On Windows: uses PowerShell toast notifications.
9+
10+
Claude Code Hook Protocol (PostToolUse):
11+
Receives JSON on stdin: {"tool_name": "...", "tool_input": {...}, ...}
12+
Exit 0: always (notification hook, never blocks)
13+
14+
FAILURE POLICY: FAIL-OPEN
15+
Notification failures must NEVER prevent tool execution.
16+
"""
17+
18+
import json
19+
import os
20+
import sys
21+
import tempfile
22+
import time
23+
24+
25+
def main() -> None:
26+
# On non-Windows, delegate to shell script
27+
if sys.platform != "win32":
28+
script_dir = os.path.dirname(os.path.abspath(__file__))
29+
shell_script = os.path.join(script_dir, "notify-on-complete.sh")
30+
if os.path.exists(shell_script):
31+
os.execv("/usr/bin/env", ["/usr/bin/env", "bash", shell_script])
32+
sys.exit(0)
33+
34+
# -----------------------------------------------------------------------
35+
# Windows path
36+
# -----------------------------------------------------------------------
37+
raw = sys.stdin.read()
38+
try:
39+
data = json.loads(raw)
40+
except (json.JSONDecodeError, ValueError):
41+
sys.exit(0)
42+
43+
tool_name = data.get("tool_name", "")
44+
tool_use_id = data.get("tool_use_id", "")
45+
46+
# Validate tool_use_id against path traversal and whitespace
47+
if (
48+
not tool_use_id
49+
or "/" in tool_use_id
50+
or ".." in tool_use_id
51+
or any(c.isspace() for c in tool_use_id)
52+
):
53+
sys.exit(0)
54+
55+
# Check if notifications are enabled
56+
if os.environ.get("SPELLBOOK_NOTIFY_ENABLED", "true").lower() != "true":
57+
sys.exit(0)
58+
59+
# Tool blacklist: interactive tools that should NOT trigger notifications
60+
blacklist = {
61+
"AskUserQuestion",
62+
"TodoRead",
63+
"TodoWrite",
64+
"TaskCreate",
65+
"TaskUpdate",
66+
"TaskGet",
67+
"TaskList",
68+
}
69+
if tool_name in blacklist:
70+
sys.exit(0)
71+
72+
# Read and delete our timer file
73+
temp_dir = tempfile.gettempdir()
74+
start_file = os.path.join(temp_dir, f"claude-notify-start-{tool_use_id}")
75+
if not os.path.exists(start_file):
76+
sys.exit(0)
77+
78+
try:
79+
with open(start_file) as f:
80+
start_time = int(f.read().strip())
81+
os.unlink(start_file)
82+
except (OSError, ValueError):
83+
sys.exit(0)
84+
85+
# Check threshold
86+
elapsed = int(time.time()) - start_time
87+
threshold = int(os.environ.get("SPELLBOOK_NOTIFY_THRESHOLD", "30"))
88+
if elapsed < threshold:
89+
sys.exit(0)
90+
91+
# Build notification content
92+
title = os.environ.get("SPELLBOOK_NOTIFY_TITLE", "Spellbook")
93+
body = f"{tool_name} finished ({elapsed}s)"
94+
95+
# Sanitize for PowerShell single quotes
96+
safe_title = title.replace("'", "''")
97+
safe_body = body.replace("'", "''")
98+
99+
# Send Windows toast notification via PowerShell
100+
import subprocess
101+
102+
# Prefer pwsh (PowerShell Core) over legacy powershell
103+
system_root = os.environ.get("SystemRoot", r"C:\Windows")
104+
pwsh_path = os.path.join(system_root, "System32", "pwsh.exe")
105+
shell = "pwsh" if os.path.exists(pwsh_path) else "powershell"
106+
107+
ps_script = f"""
108+
try {{
109+
Import-Module BurntToast -ErrorAction Stop
110+
New-BurntToastNotification -Text '{safe_title}','{safe_body}'
111+
}} catch {{
112+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
113+
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
114+
$textNodes = $template.GetElementsByTagName('text')
115+
$textNodes.Item(0).AppendChild($template.CreateTextNode('{safe_title}')) | Out-Null
116+
$textNodes.Item(1).AppendChild($template.CreateTextNode('{safe_body}')) | Out-Null
117+
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
118+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Spellbook').Show($toast)
119+
}}
120+
"""
121+
122+
# No check=True: fail-open policy for hooks
123+
subprocess.run(
124+
[shell, "-Command", ps_script],
125+
capture_output=True,
126+
timeout=10,
127+
)
128+
129+
130+
if __name__ == "__main__":
131+
main()

hooks/notify-on-complete.sh

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env bash
2+
# notify-on-complete.sh - PostToolUse hook for OS notifications
3+
#
4+
# Claude Code Hook Protocol (PostToolUse):
5+
# Receives JSON on stdin: {"tool_name":"...","tool_input":{...},
6+
# "tool_use_id":"...","session_id":"...","cwd":"...",
7+
# "transcript_path":"..."}
8+
# Exit 0: always (notification hook, never blocks)
9+
#
10+
# FAILURE POLICY: FAIL-OPEN
11+
# Notification failures must NEVER prevent tool execution.
12+
#
13+
# Calls osascript (macOS) or notify-send (Linux) directly -- no HTTP round-trip.
14+
# Reads and deletes /tmp/claude-notify-start-{tool_use_id}.
15+
16+
set -euo pipefail
17+
18+
# ---------------------------------------------------------------------------
19+
# Read hook input from stdin
20+
# ---------------------------------------------------------------------------
21+
INPUT=$(cat)
22+
23+
# ---------------------------------------------------------------------------
24+
# Extract fields
25+
# ---------------------------------------------------------------------------
26+
TOOL_NAME=$(echo "$INPUT" | python3 -c "
27+
import json, sys
28+
try: print(json.load(sys.stdin).get('tool_name', ''))
29+
except Exception: print('')
30+
")
31+
TOOL_USE_ID=$(echo "$INPUT" | python3 -c "
32+
import json, sys
33+
try: print(json.load(sys.stdin).get('tool_use_id', ''))
34+
except Exception: print('')
35+
")
36+
SESSION_ID=$(echo "$INPUT" | python3 -c "
37+
import json, sys
38+
try: print(json.load(sys.stdin).get('session_id', ''))
39+
except Exception: print('')
40+
")
41+
42+
# ---------------------------------------------------------------------------
43+
# Validate tool_use_id against path traversal
44+
# ---------------------------------------------------------------------------
45+
if [[ -z "$TOOL_USE_ID" ]] || [[ "$TOOL_USE_ID" =~ [/[:space:]] ]] || [[ "$TOOL_USE_ID" == *..* ]]; then
46+
exit 0
47+
fi
48+
49+
# ---------------------------------------------------------------------------
50+
# Check if notifications are enabled (env var, default true)
51+
# ---------------------------------------------------------------------------
52+
NOTIFY_ENABLED="${SPELLBOOK_NOTIFY_ENABLED:-true}"
53+
if [[ "$NOTIFY_ENABLED" != "true" ]]; then
54+
exit 0
55+
fi
56+
57+
# ---------------------------------------------------------------------------
58+
# Tool blacklist: interactive tools that should NOT trigger notifications
59+
# ---------------------------------------------------------------------------
60+
case "$TOOL_NAME" in
61+
AskUserQuestion|TodoRead|TodoWrite|TaskCreate|TaskUpdate|TaskGet|TaskList)
62+
exit 0
63+
;;
64+
esac
65+
66+
# ---------------------------------------------------------------------------
67+
# Read and delete our timer file
68+
# ---------------------------------------------------------------------------
69+
START_FILE="/tmp/claude-notify-start-${TOOL_USE_ID}"
70+
if [[ ! -f "$START_FILE" ]]; then
71+
exit 0
72+
fi
73+
START_TIME=$(cat "$START_FILE" 2>/dev/null || echo "")
74+
rm -f "$START_FILE"
75+
76+
if [[ -z "$START_TIME" ]]; then
77+
exit 0
78+
fi
79+
80+
# ---------------------------------------------------------------------------
81+
# Check threshold
82+
# ---------------------------------------------------------------------------
83+
NOW=$(date +%s)
84+
ELAPSED=$((NOW - START_TIME))
85+
THRESHOLD="${SPELLBOOK_NOTIFY_THRESHOLD:-30}"
86+
if [[ $ELAPSED -lt $THRESHOLD ]]; then
87+
exit 0
88+
fi
89+
90+
# ---------------------------------------------------------------------------
91+
# Build notification content
92+
# ---------------------------------------------------------------------------
93+
NOTIFY_TITLE="${SPELLBOOK_NOTIFY_TITLE:-Spellbook}"
94+
# Sanitize for shell safety - strip characters that could cause injection
95+
BODY=$(echo "${TOOL_NAME} finished (${ELAPSED}s)" | tr -d '`$(){}\\!;|&<>'"'"'"')
96+
NOTIFY_TITLE=$(echo "${NOTIFY_TITLE}" | tr -d '`$(){}\\!;|&<>'"'"'"')
97+
98+
# ---------------------------------------------------------------------------
99+
# Send platform-specific notification
100+
# ---------------------------------------------------------------------------
101+
if [[ "$(uname)" == "Darwin" ]]; then
102+
osascript -e "display notification \"${BODY}\" with title \"${NOTIFY_TITLE}\"" 2>/dev/null || true
103+
elif command -v notify-send >/dev/null 2>&1; then
104+
notify-send "$NOTIFY_TITLE" "$BODY" 2>/dev/null || true
105+
fi
106+
107+
exit 0

hooks/tts-timer-start.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ def main() -> None:
3939
if not tool_use_id:
4040
sys.exit(0)
4141

42+
now = str(int(time.time()))
43+
4244
try:
4345
start_file = Path(tempfile.gettempdir()) / f"claude-tool-start-{tool_use_id}"
44-
start_file.write_text(str(int(time.time())))
46+
start_file.write_text(now)
47+
except OSError:
48+
pass # Fail-open
49+
50+
try:
51+
notify_file = Path(tempfile.gettempdir()) / f"claude-notify-start-{tool_use_id}"
52+
notify_file.write_text(now)
4553
except OSError:
4654
pass # Fail-open
4755

hooks/tts-timer-start.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ fi
4343
# ---------------------------------------------------------------------------
4444
# Write start timestamp (fail silently)
4545
# ---------------------------------------------------------------------------
46-
date +%s > "/tmp/claude-tool-start-${TOOL_USE_ID}" 2>/dev/null || true
46+
NOW=$(date +%s)
47+
echo "${NOW}" > "/tmp/claude-tool-start-${TOOL_USE_ID}" 2>/dev/null || true
48+
echo "${NOW}" > "/tmp/claude-notify-start-${TOOL_USE_ID}" 2>/dev/null || true
4749

4850
exit 0

installer/components/hooks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
PostToolUse hooks:
1515
- Bash|Read|WebFetch|Grep|mcp__.* -> audit-log.sh (async, timeout: 10)
1616
- Bash|Read|WebFetch|Grep|mcp__.* -> canary-check.sh (timeout: 10)
17+
- (catch-all, no matcher) -> notify-on-complete.sh (async, timeout: 10)
1718
- (catch-all, no matcher) -> tts-notify.sh (async, timeout: 15)
1819
1920
PreCompact hooks:
@@ -106,6 +107,12 @@
106107
{
107108
# Catch-all: no "matcher" key means fire on every tool invocation
108109
"hooks": [
110+
{
111+
"type": "command",
112+
"command": "$SPELLBOOK_DIR/hooks/notify-on-complete.sh",
113+
"async": True,
114+
"timeout": 10,
115+
},
109116
{
110117
"type": "command",
111118
"command": "$SPELLBOOK_DIR/hooks/tts-notify.sh",
@@ -159,6 +166,7 @@
159166
"audit-log.sh": "audit_log",
160167
"canary-check.sh": "canary_check",
161168
"tts-notify.sh": "tts_notify",
169+
"notify-on-complete.sh": "notify_on_complete",
162170
"pre-compact-save.sh": "pre_compact_save",
163171
"post-compact-recover.sh": "post_compact_recover",
164172
}

0 commit comments

Comments
 (0)