|
| 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() |
0 commit comments