From 445ae5099d5d38d84746ea076484d2c1f0469d31 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 24 Mar 2026 10:17:42 +0900 Subject: [PATCH] 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); + }); +}