SLOPSHOPPER

Done Toast

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

newtoastprocessaudio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cm-done-toast
› fix the failing auth test and add an audit log call ╭───────────────╮ │ cm-done-toast │ ⏺ Read(src/auth.ts) │ Done in 42s │ ⎿ Read 6 lines ╰───────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Done Toast (cm-done-toast)

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

What it does

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.

Install

claude plugin marketplace add <owner>/<repo>
claude plugin install cm-done-toast@claudemods

Configuration (userConfig)

KeyTypeDefaultDescription
minSecondsnumber (0–3600)30Only turns at least this long raise the toast, sound and notification.
soundbooleantruePlay a short chime when the turn finishes (macOS only: Claude Code plays clips with afplay).
systemNotificationbooleanfalseAlso raise a desktop notification via osascript (macOS) or notify-send (Linux).
includeAnswerPreviewbooleanfalseAdd the first 80 characters of Claude's answer to the toast and notification.

Permissions

APIWhy
$.ui.toastThe "Done in …" message.
$.audio.playPlays the bundled sounds/done.wav chime (0.3 s). Claude Code plays clips with afplay, so the sound is macOS only.
$.process.runOnly 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.

Compatibility

Tested with Claude Code 2.1.291.

Implementation notes

  • Hook: turn.complete. The mod awaits next(e) first, so it never delays the turn's result; sound and notification are fire-and-forget.
  • Claude Code exposes no platform API, so the platform comes from a cached uname -s probe.
  • sounds/done.wav is a generated A5→E6 two-note chime (22.05 kHz, 16-bit mono).

License

MIT. See LICENSE. More at https://claudemods.app/mods/cm-done-toast.

Source 2 files
hooks/register.ts 42 lines
1import 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}
42
hooks/format.ts 73 lines
1/**
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