SLOPSHOPPER

attention-sounds

Plays a sound when Claude finishes, needs permission, asks a question, or otherwise needs attention.

newpaneguardcommandaudio
★ 3v0.2.0MITupdated 2026-10-13peeratop02/attention-sounds
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · attention-sounds
│ ┃ Notification sounds ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Notification sounds ⏺ Read(src/auth.ts) │ ┃ Choose what plays when Claude needs you. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ Default sound ▶ [ Co ⏺ Bash(bun test) │ ┃ │ Used by every event set to Default ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ Done t: ▶ [ Default · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Claude finished its turn │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Permission ▶ [ D │ ┃ │ Claude is waiting for your approval › /sound │ ┃ │ ⎿ attention-sounds: Sound panel opened. │ ┃ │ Question ▶ [ Default · │ ┃ │ Claude asked you something │ ┃ │ │ ┃ │ Other attention ▶ [ Default · Conf │ ┃ │ Any other notification │ ┃ ╰────────────────────────────────────────── │ ┃ │ ┃ ╭────────────────────────────────────────── │ ┃ │ Sound for Done [ Use default (Confirm 1) │ ┃ │ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notification sounds
Notification sounds Choose what plays when Claude needs you. ╭────────────────────────────────────────────────────────── │ Default sound ▶ [ Confirm 1 ] │ Used by every event set to Default │ │ Done t: ▶ [ Default · Confirm 1 ] │ Claude finished its turn │ │ Permission ▶ [ Default · Confirm │ Claude is waiting for your approval │ │ Question ▶ [ Default · Confirm 1 ] │ Claude asked you something │ │ Other attention ▶ [ Default · Confirm 1 ] │ Any other notification ╰────────────────────────────────────────────────────────── ╭────────────────────────────────────────────────────────╮ │ Sound for Done [ Use default (Confirm 1) ] [ Mute ] │ │ │ │ │ │ │ │ arrows move · enter hears and uses · 1-5 event · n/b │ │ library · t test · d default · m mute · esc closes Add │ │ your own: see /sound info. │ ╰────────────────────────────────────────────────────────╯
README

attention-sounds

attention-sounds showcase: click to watch with sound

▶ Watch the 36-second showcase with sound (the preview above is silent)

A Claude Code mod that plays a sound when Claude needs you, so you can do other things while it works.

EventRings when
doneClaude finished its turn
permissionClaude waits for a permission answer
questionClaude asks you a question
attentionany other notification

Each event can use its own sound, follow the default sound, or be muted. Picks are kept across sessions.

Install

At the prompt of a terminal claude session:

/plugin install attention-sounds --marketplace peeratop02/attention-sounds

Answer y to add the marketplace, then pick a scope. Installed at the user scope, it also loads in the desktop app's Code tab.

Use

/sound                      open the sound panel (or list picks)
/sound <event> <name>       use that sound for the event
/sound <event> default      follow the default sound
/sound <event> off          mute the event
/sound sound <name>         change the default sound
/sound test <name|event>    hear a sound, or what an event plays now
/sound info                 the manual and every sound name

In the desktop app, /sound opens a panel: pick an event, pick a library, click a sound to hear it and use it.

In the terminal the panel takes the keyboard:

KeyDoes
arrows / Tabmove between sounds and buttons
Enterhear the sound and use it
1-5choose the event to edit (default, done, permission, question, attention)
n / bnext / previous library
ttest what the event plays now
d / mfollow the default / mute
Escclose

Update

claude plugin update attention-sounds@peeratop02

Then /reload-plugins in a running session, or start a new one.

Sounds

Ships with 18 interface sounds from Kenney (CC0), trimmed and re-encoded.

Add your own

  1. Run /sound info. It prints the mod's sounds folder (Sounds folder: ...).
  2. Make a folder inside it, for example Mine/. Each folder shows up as a library in the panel.
  3. Drop audio files in: .m4a, .mp3, .wav, .aiff or .caf. The file name without its extension is the sound's name (Vine Boom.mp3 becomes Vine Boom).
  4. Run /sound again. No restart needed.

Good sources: /System/Library/Sounds on macOS, Kenney and freesound for free packs, any soundboard site for memes.

Short clips that start at once work best. To cut silence from both ends and even out the volume with ffmpeg:

ffmpeg -i in.mp3 -af "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-50dB,areverse,loudnorm" "Mine/My Sound.m4a"

A plugin update replaces the installed folder, so keep a copy of your own sounds somewhere else.

Notes

  • Tested on macOS only.
  • Subagent turns and aborted turns stay silent.

Develop

claude plugin validate .
claude plugin test .

Licence

Code: MIT. Bundled sounds: CC0, by Kenney.

Source 1 files
hooks/register.tsx 307 lines
1import type { Hook, PluginOptions, Register } from 'claude-code'
2
3export type Action = 'done' | 'permission' | 'question' | 'attention'
4type Engine = Parameters<Hook<'turn.complete'>>[0]
5
6// Each folder in sounds/ is a group; every file in one is a sound, named by its
7// file name without the extension and mapped to its path under sounds/.
8// The order the libraries are listed in; folders not named here follow, Mine last.
9const ORDER = ['Kenney', 'macOS', 'iOS Modern', 'iOS New', 'iOS Classic']
10let files: Map<string, string> | undefined
11let groups: Map<string, string[]> = new Map()
12async function library($: Engine): Promise<Map<string, string>> {
13  if (files === undefined) {
14    const root = `${$.plugin.root}/sounds`
15    const rank = (dir: string) => (ORDER.indexOf(dir) + 1 || ORDER.length) + (dir === 'Mine' ? ORDER.length : 0)
16    const dirs = (await $.fs.list(root)).filter(one => one.kind === 'dir').map(one => one.name).sort((x, y) => rank(x) - rank(y) || x.localeCompare(y))
17    const listed = await Promise.all(dirs.map(async dir => {
18      const names = (await $.fs.list(`${root}/${dir}`)).filter(one => one.kind === 'file').map(one => one.name)
19      return [dir, names.sort((x, y) => x.localeCompare(y))] as const
20    }))
21    const strip = (name: string) => name.replace(/\.[^.]+$/, '')
22    groups = new Map(listed.map(([dir, names]) => [dir, names.map(strip)]))
23    files = new Map(listed.flatMap(([dir, names]) => names.map(name => [strip(name), `${dir}/${name}`] as const)))
24  }
25  return files
26}
27
28// What the panel shows: the event being edited and the library listed for it.
29// ponytail: module variables, so a hot reload resets them; move to $.state if that matters.
30let editing = 'done'
31let shown: string | undefined
32const ROOM = 60
33
34const HINTS: Record<string, string> = {
35  sound: 'Used by every event set to Default',
36  done: 'Claude finished its turn',
37  permission: 'Claude is waiting for your approval',
38  question: 'Claude asked you something',
39  attention: 'Any other notification',
40}
41
42const ACCENT = '#D97757'
43const icon = (paths: string): string =>
44  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ACCENT}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
45const ICONS: Record<string, string> = {
46  bell: icon('<path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9z"/><path d="M10 20a2 2 0 0 0 4 0"/>'),
47  sound: icon('<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M15.5 9a4.5 4.5 0 0 1 0 6"/><path d="M18 6.5a8 8 0 0 1 0 11"/>'),
48  done: icon('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5.5-6.5"/>'),
49  permission: icon('<path d="M12 3l7.5 3v5.5c0 4.5-3.2 8-7.5 9.5-4.3-1.5-7.5-5-7.5-9.5V6z"/><path d="M12 9.5v3.5"/><path d="M12 16v.1"/>'),
50  question: icon('<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.6 2.6 0 1 1 3.8 2.3c-.8.5-1.3 1-1.3 2"/><path d="M12 17v.1"/>'),
51  attention: icon('<path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9z"/><path d="M10 20a2 2 0 0 0 4 0"/>'),
52}
53
54// Case and spaces are ignored, so `notmarimba` and a pick saved as `NotMarimba` find `Not Marimba`.
55const fold = (name: string) => name.toLowerCase().replace(/[\s_]+/g, '')
56const named = (sounds: Map<string, string>, what: string): string | undefined =>
57  [...sounds.keys()].find(one => fold(one) === fold(what))
58
59const FIELDS = ['sound', 'done', 'permission', 'question', 'attention']
60const PANE = 'sounds'
61const LABELS: Record<string, string> = {
62  sound: 'Default sound',
63  done: 'Done',
64  permission: 'Permission',
65  question: 'Question',
66  attention: 'Other attention',
67}
68
69// Notification kinds that already ring through their own event.
70const ALREADY_RUNG = new Set(['permission_prompt', 'idle_prompt'])
71
72// A pick made with /sound (kept in $.store) wins over the manifest's option.
73async function pick($: Engine, options: PluginOptions, field: string): Promise<string> {
74  const stored = await $.store.get(field)
75  const raw = typeof stored === 'string' ? stored : String(options[field])
76  return named(await library($), raw) ?? raw
77}
78
79async function play($: Engine, options: PluginOptions, action: Action): Promise<void> {
80  const own = await pick($, options, action)
81  const name = own === 'default' ? await pick($, options, 'sound') : own
82  const file = (await library($)).get(name)
83  if (file !== undefined) await $.audio.play({ asset: `sounds/${file}` })
84}
85
86// Never awaited: a sound must not hold up the event it announces.
87function ring($: Engine, options: PluginOptions, action: Action): void {
88  play($, options, action).catch((err: unknown) => $.ui.log(`${$.plugin.name}: ${action} sound failed: ${String(err)}`))
89}
90
91const INFO = [
92  'Plays a sound when Claude needs you.',
93  '',
94  'Events:',
95  '  done        Claude finished its turn',
96  '  permission  Claude waits for a permission answer',
97  '  question    Claude asks you a question',
98  '  attention   any other notification',
99  '',
100  'Commands:',
101  '  /sound                      open the sound panel (or list picks)',
102  '  /sound <event> <name>       use that sound for the event',
103  '  /sound <event> default      follow the default sound',
104  '  /sound <event> off          mute the event',
105  '  /sound sound <name>         change the default sound',
106  '  /sound test <name|event>    hear a sound, or what an event plays now',
107  '  /sound info                 this manual',
108  '',
109  'Sounds: each folder in the mod\'s sounds/ is a group; drop a file in one to add it.',
110  'Picks are kept across sessions.',
111].join('\n')
112
113const names = async ($: Engine): Promise<string> => {
114  await library($)
115  return [...groups].map(([group, list]) => `${group}: ${list.join(', ')}`).join('\n')
116}
117
118// What `/sound test <what>` plays: a sound by name, or an event's current pick.
119async function resolve($: Engine, options: PluginOptions, what: string): Promise<string | undefined> {
120  const sound = named(await library($), what)
121  if (sound !== undefined) return sound
122  if (!FIELDS.includes(what)) return undefined
123  const own = await pick($, options, what)
124  return own === 'default' ? pick($, options, 'sound') : own
125}
126
127async function show($: Engine, options: PluginOptions): Promise<string> {
128  const lines = await Promise.all(FIELDS.map(async field => `${field}: ${await pick($, options, field)}`))
129  return [...lines, `Usage: /sound <${FIELDS.join('|')}> <name|default|off>  ·  /sound test <name|event>  ·  /sound info`, await names($)].join('\n')
130}
131
132export const register: Register = (on, options) => {
133  on('session.start', async ($, e, next) => {
134    await $.command.register({
135      name: 'sound',
136      description: 'Show or set notification sounds',
137      argumentHint: '[event] [name|default|off] | test [name|event] | info',
138    })
139    return next(e)
140  })
141
142  on('command.run', { command: 'sound' }, async ($, e) => {
143    const [field, ...rest] = e.args.trim().split(/\s+/)
144    const value = rest.length > 0 ? rest.join(' ') : undefined
145    const sounds = await library($)
146    if (field === 'info') return { text: `${INFO}\n\nSounds folder: ${$.plugin.root}/sounds\n\n${await names($)}` }
147    if (field === 'test') {
148      const name = await resolve($, options, value ?? 'sound')
149      if (name === undefined) return { text: `Unknown "${value}". Give a sound name or an event: ${FIELDS.join(', ')}` }
150      if (name === 'off') return { text: `${value} is off: nothing plays.` }
151      void $.audio.play({ asset: `sounds/${sounds.get(name)}` }).catch(() => undefined)
152      return { text: `Playing ${name}` }
153    }
154    if (!field) {
155      // Listed afresh on each open, so a file dropped into sounds/ shows up without a reload.
156      files = undefined
157      // The panel where a surface draws it, the same picks as text where none does.
158      const { isPlaced } = await $.ui.open({ id: PANE, title: 'Notification sounds', focus: true, closeOnEscape: true })
159      return { text: isPlaced ? 'Sound panel opened.' : await show($, options) }
160    }
161    if (!FIELDS.includes(field) || value === undefined) return { text: await show($, options) }
162    const special = field === 'sound' ? [] : ['default', 'off']
163    const picked = special.find(one => one === value.toLowerCase()) ?? named(sounds, value)
164    if (picked === undefined) return { text: `Unknown sound "${value}". ${await names($)}` }
165    await $.store.set(field, picked)
166    if (sounds.has(picked)) void $.audio.play({ asset: `sounds/${sounds.get(picked)}` }).catch(() => undefined)
167    return { text: `${field} sound: ${picked}` }
168  })
169
170  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
171    const { Box, Text, Button } = $.ui.resolve(e)
172    // The terminal's table has no Svg: the icons are for the other surfaces.
173    const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
174    const sounds = await library($)
175    const picks = Object.fromEntries(await Promise.all(FIELDS.map(async field => [field, await pick($, options, field)] as const)))
176    const current = picks[editing] ?? 'default'
177    // An event on Default or Mute opens on the default sound's library.
178    const heard = sounds.has(current) ? current : picks.sound ?? ''
179    const home = [...groups].find(([, list]) => list.includes(heard))?.[0]
180    const group = shown !== undefined && groups.has(shown) ? shown : home ?? [...groups.keys()][0] ?? ''
181    const chips = (groups.get(group) ?? []).slice(0, ROOM)
182    // Terminal only: hotkeys, and the focus ring starting on the current sound.
183    const key = (hotkey: string | undefined) => (e.surface === 'terminal' && hotkey !== undefined ? { hotkey } : {})
184    const tabs = [...groups.keys()].slice(0, ROOM)
185    const at = tabs.indexOf(group)
186    const start = chips.includes(current) ? current : chips[0]
187    const assign = async (name: string) => {
188      await $.store.set(editing, name)
189      play(name === 'default' ? picks.sound ?? '' : name)
190      $.ui.invalidate('ui.render')
191    }
192    const play = (name: string) => {
193      const file = sounds.get(name)
194      if (file !== undefined) void $.audio.play({ asset: `sounds/${file}` }).catch(() => undefined)
195    }
196    const described = (field: string) =>
197      picks[field] === 'default' ? `Default · ${picks.sound}` : picks[field] === 'off' ? 'Muted' : picks[field]
198
199    return (
200      <Box flexDirection="column" gap={1} padding={1}>
201        <Box flexDirection="row" gap={1} alignItems="center">
202          {Svg && <Svg source={ICONS.bell ?? ''} alt="Bell" width={28} height={28} />}
203          <Box flexDirection="column">
204            <Text bold>Notification sounds</Text>
205            <Text dimColor>Choose what plays when Claude needs you.</Text>
206          </Box>
207        </Box>
208
209        <Box flexDirection="column" gap={1} paddingX={1} borderStyle="round" borderColor="subtle">
210          {FIELDS.map(field => (
211            <Box key={`row-${field}`} flexDirection="row" gap={1} alignItems="center">
212              {Svg && <Svg source={ICONS[field] ?? ''} alt={LABELS[field] ?? field} width={20} height={20} />}
213              <Box flexDirection="column" flexGrow={1}>
214                <Text bold color={field === editing ? 'claude' : 'text'}>
215                  {LABELS[field]}
216                </Text>
217                <Text dimColor>{HINTS[field]}</Text>
218              </Box>
219              <Button key={`play-${field}`} plain dimColor {...key(field === editing ? 't' : undefined)} onPress={async () => play((await resolve($, options, field)) ?? '')}>
220                ▶
221              </Button>
222              <Button
223                key={`edit-${field}`}
224                {...key(String(FIELDS.indexOf(field) + 1))}
225                variant={field === editing ? 'primary' : 'secondary'}
226                onPress={() => {
227                  editing = field
228                  shown = undefined
229                  $.ui.invalidate('ui.render')
230                }}
231              >
232                {described(field)}
233              </Button>
234            </Box>
235          ))}
236        </Box>
237
238        <Box flexDirection="column" gap={1} paddingX={1} borderStyle="round" borderColor="claude">
239          <Box flexDirection="row" gap={1} alignItems="center">
240            <Box flexGrow={1}>
241              <Text bold>
242                Sound for <Text color="claude">{LABELS[editing]}</Text>
243              </Text>
244            </Box>
245            {editing !== 'sound' &&
246              ['default', 'off'].map(name => (
247                <Button key={`chip-${name}`} variant={name === current ? 'primary' : 'secondary'} {...key(name === 'default' ? 'd' : 'm')} onPress={() => assign(name)}>
248                  {name === 'default' ? `Use default (${picks.sound})` : 'Mute'}
249                </Button>
250              ))}
251          </Box>
252          <Box flexDirection="row" flexWrap="wrap" gap={1}>
253            {tabs.map((name, i) => (
254              <Button
255                key={`lib-${name}`}
256                {...key(tabs.length < 2 ? undefined : i === (at + 1) % tabs.length ? 'n' : i === (at + tabs.length - 1) % tabs.length ? 'b' : undefined)}
257                plain
258                dimColor={name !== group}
259                onPress={() => {
260                  shown = name
261                  $.ui.invalidate('ui.render')
262                }}
263              >
264                {name === group ? `● ${name}` : name} <Text dimColor>{groups.get(name)?.length}</Text>
265              </Button>
266            ))}
267          </Box>
268          <Box flexDirection="row" flexWrap="wrap" gap={1}>
269            {chips.map(name => (
270              <Button key={`chip-${name}`} variant={name === current ? 'primary' : 'secondary'} {...(name === start ? { autoFocus: true as const } : {})} onPress={() => assign(name)}>
271                {name}
272              </Button>
273            ))}
274          </Box>
275          <Text dimColor>
276            {e.surface === 'terminal'
277              ? 'arrows move · enter hears and uses · 1-5 event · n/b library · t test · d default · m mute · esc closes'
278              : 'Click a sound to hear it and use it.'}{' '}
279            Add your own: see /sound info.
280          </Text>
281        </Box>
282      </Box>
283    )
284  })
285
286  on('turn.complete', ($, e, next) => {
287    // Subagent turns and the user's own interrupts need no ring.
288    if (e.agentId === undefined && !e.isAborted) ring($, options, 'done')
289    return next(e)
290  })
291
292  on('classic.PermissionRequest', ($, e, next) => {
293    ring($, options, 'permission')
294    return next(e)
295  })
296
297  on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
298    ring($, options, 'question')
299    return next(e)
300  })
301
302  on('classic.Notification', ($, e, next) => {
303    if (!ALREADY_RUNG.has(e.notification_type)) ring($, options, 'attention')
304    return next(e)
305  })
306}
307