Shows "Done in 2m 14s" and plays a short chime when a long Claude turn finishes; optional macOS/Linux system notification.

cm-done-toast)Shows "Done in 2m 14s" and plays a short chime when a long Claude turn finishes; optional macOS/Linux system notification.
When a turn that took a while finishes, you hear a short chime and see a toast:
Done in 2m 14s
Turns shorter than minSeconds stay quiet, as do interrupted turns and subagent turns. A turn that ends on an error says Stopped on an error after 2m 14s. Optionally raises a system notification (macOS Notification Center or Linux notify-send) so you notice from another window, and can add the first 80 characters of Claude's answer.
claude plugin marketplace add <owner>/<repo>
claude plugin install cm-done-toast@claudemods
| Key | Type | Default | Description |
|---|---|---|---|
minSeconds | number (0–3600) | 30 | Only turns at least this long raise the toast, sound and notification. |
sound | boolean | true | Play a short chime when the turn finishes (macOS only: Claude Code plays clips with afplay). |
systemNotification | boolean | false | Also raise a desktop notification via osascript (macOS) or notify-send (Linux). |
includeAnswerPreview | boolean | false | Add the first 80 characters of Claude's answer to the toast and notification. |
| API | Why |
|---|---|
$.ui.toast | The "Done in …" message. |
$.audio.play | Plays the bundled sounds/done.wav chime (0.3 s). Claude Code plays clips with afplay, so the sound is macOS only. |
$.process.run | Only when systemNotification is on: uname -s once to pick the platform, then osascript -e 'display notification …' (macOS) or notify-send (Linux). Arguments are passed as an argv array, with no shell. |
Tested with Claude Code 2.1.291.
turn.complete. The mod awaits next(e) first, so it never delays the turn's result; sound and notification are fire-and-forget.uname -s probe.sounds/done.wav is a generated A5→E6 two-note chime (22.05 kHz, 16-bit mono).MIT. See LICENSE. More at https://claudemods.app/mods/cm-done-toast.
hooks/register.ts 42 lines1import type { Register } from 'claude-code'
2
3import { notificationArgv, parseUname, shouldNotify, toastText } from './format'
4import type { Platform } from './format'
5
6export const register: Register = (on, options) => {
7 const minSeconds = typeof options.minSeconds === 'number' ? options.minSeconds : 30
8 const sound = options.sound !== false
9 const systemNotification = options.systemNotification === true
10 const includeAnswerPreview = options.includeAnswerPreview === true
11
12 // Probed once per load, and only when system notifications are on: the
13 // mods API exposes no platform field, so ask the host's `uname -s`.
14 let platform: Promise<Platform | undefined> | undefined
15
16 on('turn.complete', async ($, e, next) => {
17 const result = await next(e)
18 if (!shouldNotify(e, minSeconds)) return result
19
20 const text = toastText(e, includeAnswerPreview)
21 $.ui.toast(text)
22
23 if (sound) {
24 $.audio.play({ asset: 'sounds/done.wav' }).catch(() => undefined)
25 }
26
27 if (systemNotification) {
28 platform ??= $.process
29 .run(['uname', '-s'], { timeoutMs: 5000 })
30 .then(ran => (ran.exitCode === 0 ? parseUname(ran.stdout) : undefined))
31 .catch(() => undefined)
32 platform
33 .then(found =>
34 found === undefined ? undefined : $.process.run(notificationArgv(found, 'Claude Code', text), { timeoutMs: 10000 }),
35 )
36 .catch(() => undefined)
37 }
38
39 return result
40 })
41}
42hooks/format.ts 73 lines1/**
2 * Pure helpers for cm-done-toast: no `$`, so they are unit-tested directly.
3 */
4
5/** `2m 14s`, `45s`, `1h 3m`: the turn's wall-clock length, rounded to seconds. */
6export function formatDuration(ms: number): string {
7 const total = Math.max(0, Math.round(ms / 1000))
8 const h = Math.floor(total / 3600)
9 const m = Math.floor((total % 3600) / 60)
10 const s = total % 60
11 if (h > 0) return `${h}h ${m}m`
12 if (m > 0) return `${m}m ${s}s`
13 return `${s}s`
14}
15
16/** The fields of `turn.complete` this mod reads. */
17export type TurnFacts = {
18 durationMs: number
19 isAborted: boolean
20 agentId?: string
21 reason: string
22}
23
24/**
25 * Whether a finished turn deserves a toast: the main loop's (no `agentId`),
26 * not interrupted, and at least `minSeconds` long.
27 */
28export function shouldNotify(turn: TurnFacts, minSeconds: number): boolean {
29 if (turn.agentId !== undefined) return false
30 if (turn.isAborted || turn.reason === 'aborted') return false
31 return turn.durationMs >= Math.max(0, minSeconds) * 1000
32}
33
34/** The first `max` characters of the answer on one line, or '' when empty. */
35export function preview(answer: string, max = 80): string {
36 const line = answer.replace(/\s+/g, ' ').trim()
37 if (line.length <= max) return line
38 return `${line.slice(0, max - 1).trimEnd()}…`
39}
40
41/** The toast text: `Done in 2m 14s`, or with the answer's opening words. */
42export function toastText(turn: TurnFacts & { answer: string }, withPreview: boolean): string {
43 const head = turn.reason === 'error' ? `Stopped on an error after ${formatDuration(turn.durationMs)}` : `Done in ${formatDuration(turn.durationMs)}`
44 const tail = withPreview ? preview(turn.answer) : ''
45 return tail === '' ? head : `${head} · ${tail}`
46}
47
48/** `Darwin` / `Linux` from `uname -s`, or undefined for anything else. */
49export type Platform = 'darwin' | 'linux'
50
51export function parseUname(stdout: string): Platform | undefined {
52 const name = stdout.trim().toLowerCase()
53 if (name === 'darwin') return 'darwin'
54 if (name === 'linux') return 'linux'
55 return undefined
56}
57
58/** Escapes a string for an AppleScript double-quoted literal. */
59function appleString(text: string): string {
60 return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
61}
62
63/**
64 * The argv that raises a desktop notification on this platform: osascript
65 * on macOS, notify-send (libnotify) on Linux. No shell is involved.
66 */
67export function notificationArgv(platform: Platform, title: string, body: string): string[] {
68 if (platform === 'darwin') {
69 return ['osascript', '-e', `display notification ${appleString(body)} with title ${appleString(title)}`]
70 }
71 return ['notify-send', '--app-name=Claude Code', title, body]
72}
73