A desktop notification when a long turn ends, telling you whether Claude is done or waiting for you

A Claude Code mod from Encore Mods. Look away while Claude works.
terminal-notifier when installed (brew install terminal-notifier), then osascript, then notify-send on Linux./plugin marketplace add benjaminb10/encore-mods
/plugin install done-notify@encore-mods
Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.
min_seconds: the shortest turn that notifies (default 15). Set it in /plugin, or with /plugin configure done-notify@encore-mods.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/done-notifyhooks/register.js 55 lines1// done-notify: a desktop notification when a turn that took a while ends, so you can look away while
2// Claude works. "Claude is waiting for you" when the reply ends with a question or a list of choices,
3// "Claude is done" otherwise. On macOS, clicking it brings back the terminal app you run Claude in.
4
5let minMs = 15000
6
7function clean(s) {
8 return String(s).replace(/\*\*|__|`|#+\s*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\s+/g, ' ').trim()
9}
10
11export function summary(answer) {
12 const first = clean((answer || '').split('\n').find((l) => l.trim()) || 'Turn finished')
13 return first.length > 140 ? first.slice(0, 139) + '…' : first
14}
15
16export function isWaiting(answer) {
17 const text = (answer || '').trim()
18 if (/\?\s*\**$/.test(text)) return true
19 const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
20 const tail = []
21 while (lines.length && /^(?:\d+[.)]|[-*•])\s+/.test(lines[lines.length - 1])) tail.unshift(lines.pop())
22 return tail.length >= 2 && lines.length > 0 && /[?:]\s*\**$/.test(lines[lines.length - 1])
23}
24
25async function notify($, title, message) {
26 const app = $.env.get('__CFBundleIdentifier')
27 const tn = ['-title', title, '-message', message, '-sound', 'Glass', '-group', 'done-notify-' + $.session.id()]
28 if (app) tn.push('-activate', app)
29 const tries = [
30 ['terminal-notifier', ...tn],
31 ['/opt/homebrew/bin/terminal-notifier', ...tn],
32 ['/usr/local/bin/terminal-notifier', ...tn],
33 ['osascript', '-e', 'display notification ' + JSON.stringify(message) + ' with title ' + JSON.stringify(title) + ' sound name "Glass"'],
34 ['notify-send', title, message],
35 ]
36 for (const cmd of tries) {
37 try {
38 const r = await $.process.run(cmd)
39 if (r.exitCode === 0) return
40 } catch {}
41 }
42}
43
44export function register(on, options = {}) {
45 const s = Number(options.min_seconds)
46 if (Number.isFinite(s) && s >= 0) minMs = s * 1000
47
48 on('turn.complete', async ($, e, next) => {
49 if (!e.agentId && !e.isAborted && e.durationMs >= minMs) {
50 await notify($, isWaiting(e.answer) ? 'Claude is waiting for you' : 'Claude is done', summary(e.answer))
51 }
52 return next(e)
53 })
54}
55