Created
March 11, 2026 09:36
-
-
Save upman/663ceda57d915f2a319dc8fd917db933 to your computer and use it in GitHub Desktop.
Get notifications on mac OS when claude needs input
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Notification Hook - Sends macOS notifications when Claude needs user input. | |
| * Logs to: ~/.claude/hooks-logs/YYYY-MM-DD.jsonl | |
| * | |
| * Setup in .claude/settings.json: | |
| * { | |
| * "hooks": { | |
| * "Notification": [{ | |
| * "matcher": "permission_prompt|idle_prompt|elicitation_dialog", | |
| * "hooks": [{ "type": "command", "command": "node /path/to/notify-permission.js" }] | |
| * }] | |
| * } | |
| * } | |
| */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { exec } = require('child_process'); | |
| const { promisify } = require('util'); | |
| const execAsync = promisify(exec); | |
| const LOG_DIR = path.join(process.env.HOME, '.claude', 'hooks-logs'); | |
| function log(data) { | |
| try { | |
| if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); | |
| const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); | |
| fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), hook: 'notify-permission', ...data }) + '\n'); | |
| } catch {} | |
| } | |
| function getNotificationType(data) { | |
| if (data.notification_type) return data.notification_type; | |
| const msg = (data.message || '').toLowerCase(); | |
| if (msg.includes('permission') || msg.includes('approve')) return 'permission_prompt'; | |
| if (msg.includes('idle') || msg.includes('waiting')) return 'idle_prompt'; | |
| if (msg.includes('elicitation') || msg.includes('mcp')) return 'elicitation_dialog'; | |
| return 'notification'; | |
| } | |
| function getProjectName(cwd) { | |
| return cwd ? path.basename(cwd) : 'unknown'; | |
| } | |
| function getShortSessionId(sessionId) { | |
| return sessionId ? sessionId.slice(0, 6) : '????'; | |
| } | |
| function getEmoji(type) { | |
| return { permission_prompt: 'π', idle_prompt: 'π€', elicitation_dialog: 'π§' }[type] || 'π'; | |
| } | |
| function getTitle(type, message) { | |
| const msg = (message || '').toLowerCase(); | |
| if (type === 'elicitation_dialog' || msg.includes('select') || msg.includes('choose') || msg.includes('which')) { | |
| return 'Claude needs your choice'; | |
| } | |
| if (type === 'permission_prompt') { | |
| if (msg.includes('bash') || msg.includes('command')) return 'Claude needs permission (Bash)'; | |
| if (msg.includes('write') || msg.includes('create file')) return 'Claude needs permission (Write)'; | |
| if (msg.includes('edit') || msg.includes('modify')) return 'Claude needs permission (Edit)'; | |
| if (msg.includes('read')) return 'Claude needs permission (Read)'; | |
| return 'Claude needs your attention'; | |
| } | |
| if (type === 'idle_prompt') return 'Claude is waiting for you'; | |
| return 'Claude notification'; | |
| } | |
| function formatMessage(message) { | |
| if (!message) return '_No details provided_'; | |
| return message.length > 200 ? message.slice(0, 200) + '...' : message; | |
| } | |
| async function sendMacNotification(title, message) { | |
| try { | |
| const escapedTitle = title.replace(/"/g, '\\"'); | |
| const escapedMessage = message.replace(/"/g, '\\"'); | |
| await execAsync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`); | |
| try { | |
| await execAsync(`osascript -e 'beep'`); | |
| } catch { | |
| // Beep is optional, continue if it fails | |
| } | |
| return { channel: 'mac', sent: true }; | |
| } catch (e) { | |
| return { channel: 'mac', sent: false, error: e.message }; | |
| } | |
| } | |
| async function sendAll(data, type) { | |
| const title = getTitle(type, data.message); | |
| const message = formatMessage(data.message); | |
| return Promise.all([sendMacNotification(title, message)]); | |
| } | |
| async function main() { | |
| let input = ''; | |
| for await (const chunk of process.stdin) input += chunk; | |
| try { | |
| const data = JSON.parse(input); | |
| if (data.hook_event_name !== 'Notification') return console.log('{}'); | |
| log({ level: 'INPUT', notification_type: data.notification_type, message: data.message, session_id: data.session_id }); | |
| const type = getNotificationType(data); | |
| const results = await sendAll(data, type); | |
| const sent = results.filter(r => r.sent).map(r => r.channel); | |
| const failed = results.filter(r => !r.sent && r.error); | |
| log({ level: sent.length ? 'SENT' : 'NONE', type, sent, failed, session_id: data.session_id }); | |
| console.log('{}'); | |
| } catch (e) { | |
| log({ level: 'ERROR', error: e.message }); | |
| console.log('{}'); | |
| } | |
| } | |
| if (require.main === module) { | |
| main(); | |
| } else { | |
| module.exports = { | |
| getNotificationType, | |
| getProjectName, | |
| getShortSessionId, | |
| getEmoji, | |
| getTitle, | |
| formatMessage, | |
| sendMacNotification, | |
| sendAll, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment