A notification, and a sound on macOS, when a long turn finishes: work in another window and come back when Claude is done. Names the project and how long it…

Start a long task, go do something else, get called back when Claude is done.
jr done
shop: done in 4 min 12 s
/config)| Option | Default | |
|---|---|---|
| Only after (seconds) | 120 | Minimum 5 |
| Sound (macOS) | Glass | none, Glass, Hero, Ping, Submarine, Funk, Purr |
/plugin install jr-done-ping --marketplace jrlprost/claude-code-mods
Raises notifications. Runs afplay with a sound from /System/Library/Sounds. Sends nothing over the network.
hooks/register.ts 28 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { message, shouldPing, soundFile } from './ping'
4
5async function ping($: EngineInterface, text: string, sound: string | null) {
6 await $.ui.notify(text, { title: 'jr done' })
7 if (!sound) return
8 try {
9 await $.process.run(['afplay', sound])
10 } catch {
11 // No afplay off macOS: the notification alone stands.
12 }
13}
14
15export const register: Register = (on, options) => {
16 const minSeconds = Math.max(5, Number(options.minSeconds ?? 120) || 120)
17 const sound = soundFile(String(options.sound ?? 'Glass'))
18
19 on('turn.complete', async ($, e, next) => {
20 const done = await next(e)
21 if (!shouldPing(e, minSeconds)) return done
22 const root = await $.session.root()
23 const project = root.split('/').filter(Boolean).pop() ?? root
24 await ping($, message(project, e), sound)
25 return done
26 })
27}
28hooks/ping.ts 20 lines1export const SOUNDS = ['none', 'Glass', 'Hero', 'Ping', 'Submarine', 'Funk', 'Purr'] as const
2
3/** "42 s", "4 min 12 s", "1 h 05 min" */
4export const spoken = (ms: number) => {
5 const s = Math.round(ms / 1000)
6 if (s < 60) return `${s} s`
7 if (s < 3600) return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`
8 return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')} min`
9}
10
11type Done = { durationMs: number; isAborted: boolean; agentId?: string; reason?: string }
12
13/** Ping for the main loop's long turns that ended on their own (not interrupted). */
14export const shouldPing = (e: Done, minSeconds: number) => e.agentId === undefined && !e.isAborted && e.durationMs >= minSeconds * 1000
15
16export const message = (project: string, e: Done) =>
17 e.reason === 'error' ? `${project}: stopped on an error after ${spoken(e.durationMs)}` : `${project}: done in ${spoken(e.durationMs)}`
18
19export const soundFile = (sound: string) => (sound === 'none' || !SOUNDS.includes(sound as never) ? null : `/System/Library/Sounds/${sound}.aiff`)
20