From 445ae5099d5d38d84746ea076484d2c1f0469d31 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 24 Mar 2026 10:17:42 +0900 Subject: [PATCH 1/3] feat: add macOS desktop notification Stop hook Add a new Stop hook that sends a native macOS notification with the task summary (first line of last_assistant_message) when Claude finishes responding. Uses osascript via spawnSync for shell injection safety. Supports run-with-flags fast require() path. Only active on standard and strict profiles; silently skips on non-macOS platforms. --- hooks/README.md | 1 + hooks/hooks.json | 12 +++++ scripts/hooks/desktop-notify.js | 88 +++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 scripts/hooks/desktop-notify.js diff --git a/hooks/README.md b/hooks/README.md index 490c09ba..e3d50e51 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -48,6 +48,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Session summary** | `Stop` | Persists session state when transcript path is available | | **Pattern extraction** | `Stop` | Evaluates session for extractable patterns (continuous learning) | | **Cost tracker** | `Stop` | Emits lightweight run-cost telemetry markers | +| **Desktop notify** | `Stop` | Sends macOS desktop notification with task summary (standard+) | | **Session end marker** | `SessionEnd` | Lifecycle marker and cleanup log | ## Customizing Hooks diff --git a/hooks/hooks.json b/hooks/hooks.json index 2b38e94f..d49b401a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -289,6 +289,18 @@ } ], "description": "Track token and cost metrics per session" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"stop:desktop-notify\" \"scripts/hooks/desktop-notify.js\" \"standard,strict\"", + "async": true, + "timeout": 5 + } + ], + "description": "Send macOS desktop notification with task summary when Claude responds" } ], "SessionEnd": [ diff --git a/scripts/hooks/desktop-notify.js b/scripts/hooks/desktop-notify.js new file mode 100644 index 00000000..1e849b73 --- /dev/null +++ b/scripts/hooks/desktop-notify.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Desktop Notification Hook (Stop) + * + * Sends a native desktop notification with the task summary when Claude + * finishes responding. Currently supports macOS (osascript); other + * platforms exit silently. Windows (PowerShell) and Linux (notify-send) + * support is planned. + * + * Hook ID : stop:desktop-notify + * Profiles: standard, strict + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const { isMacOS, log } = require('../lib/utils'); + +const TITLE = 'Claude Code'; +const MAX_BODY_LENGTH = 100; + +/** + * Extract a short summary from the last assistant message. + * Takes the first non-empty line and truncates to MAX_BODY_LENGTH chars. + */ +function extractSummary(message) { + if (!message || typeof message !== 'string') return 'Done'; + + const firstLine = message + .split('\n') + .map(l => l.trim()) + .find(l => l.length > 0); + + if (!firstLine) return 'Done'; + + return firstLine.length > MAX_BODY_LENGTH + ? `${firstLine.slice(0, MAX_BODY_LENGTH)}...` + : firstLine; +} + +/** + * Send a macOS notification via osascript. + * Uses spawnSync with an argument array to avoid shell injection. + */ +function notifyMacOS(title, body) { + const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`; + spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 }); +} + +// TODO: future platform support +// function notifyWindows(title, body) { ... } +// function notifyLinux(title, body) { ... } + +/** + * Fast-path entry point for run-with-flags.js (avoids extra process spawn). + */ +function run(raw) { + try { + if (!isMacOS) return raw; + + const input = raw.trim() ? JSON.parse(raw) : {}; + const summary = extractSummary(input.last_assistant_message); + notifyMacOS(TITLE, summary); + } catch (err) { + log(`[DesktopNotify] Error: ${err.message}`); + } + + return raw; +} + +module.exports = { run }; + +// Legacy stdin path (when invoked directly rather than via run-with-flags) +if (require.main === module) { + const MAX_STDIN = 1024 * 1024; + let data = ''; + + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (data.length < MAX_STDIN) { + data += chunk.substring(0, MAX_STDIN - data.length); + } + }); + process.stdin.on('end', () => { + const output = run(data); + if (output) process.stdout.write(output); + }); +} From d3699f90109005e7c39758298bb97fd80cc7a5c6 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 24 Mar 2026 10:36:00 +0900 Subject: [PATCH 2/3] fix: use AppleScript-safe escaping and reduce spawnSync timeout - Replace JSON.stringify with curly quote substitution for AppleScript compatibility (AppleScript does not support \" backslash escapes) - Reduce spawnSync timeout from 5000ms to 3000ms to leave headroom within the 5s hook deadline --- scripts/hooks/desktop-notify.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/desktop-notify.js b/scripts/hooks/desktop-notify.js index 1e849b73..a9bd2540 100644 --- a/scripts/hooks/desktop-notify.js +++ b/scripts/hooks/desktop-notify.js @@ -40,11 +40,14 @@ function extractSummary(message) { /** * Send a macOS notification via osascript. - * Uses spawnSync with an argument array to avoid shell injection. + * AppleScript strings do not support backslash escapes, so we replace + * double quotes with curly quotes and strip backslashes before embedding. */ function notifyMacOS(title, body) { - const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`; - spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 }); + const safeBody = body.replace(/\\/g, '').replace(/"/g, '\u201C'); + const safeTitle = title.replace(/\\/g, '').replace(/"/g, '\u201C'); + const script = `display notification "${safeBody}" with title "${safeTitle}"`; + spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 3000 }); } // TODO: future platform support From f6b10481f3d4fefeaab8ac541b7087d7fa308a7a Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 24 Mar 2026 10:55:15 +0900 Subject: [PATCH 3/3] fix: add spawnSync error logging and restore 5s timeout - Check spawnSync result and log warning on failure via stderr - Restore osascript timeout to 5000ms, increase hook deadline to 10s for sufficient headroom --- hooks/hooks.json | 2 +- scripts/hooks/desktop-notify.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index d49b401a..d8bdef3c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -297,7 +297,7 @@ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"stop:desktop-notify\" \"scripts/hooks/desktop-notify.js\" \"standard,strict\"", "async": true, - "timeout": 5 + "timeout": 10 } ], "description": "Send macOS desktop notification with task summary when Claude responds" diff --git a/scripts/hooks/desktop-notify.js b/scripts/hooks/desktop-notify.js index a9bd2540..8d844e03 100644 --- a/scripts/hooks/desktop-notify.js +++ b/scripts/hooks/desktop-notify.js @@ -47,7 +47,10 @@ function notifyMacOS(title, body) { const safeBody = body.replace(/\\/g, '').replace(/"/g, '\u201C'); const safeTitle = title.replace(/\\/g, '').replace(/"/g, '\u201C'); const script = `display notification "${safeBody}" with title "${safeTitle}"`; - spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 3000 }); + const result = spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 }); + if (result.error || result.status !== 0) { + log(`[DesktopNotify] osascript failed: ${result.error ? result.error.message : `exit ${result.status}`}`); + } } // TODO: future platform support