SLOPSHOPPER

task-chime

Plays a sound, with optional on-screen signals, when a task is complete.

newpanebandcommandtoaststatus
★ 1v0.6.0NOASSERTIONupdated 2026-10-08ialoni66/task-chime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-chime
│ ┃ Task chime ✕ › fix the failing auth test and add an audit log call │ ┃ Task chime settings │ ┃ Sound: on [ Mute ] ⏺ Read(src/auth.ts) │ ┃ Volume: 75 [ Quieter ] [ Louder ] ⎿ Read 6 lines │ ┃ Loudness boost: 2 copies [ Fewer ] [ More ⏺ Update(src/auth.ts) │ ┃ Bluetooth wake-up delay: 1000 ms [ Shorter ⎿ Added 2 lines, removed 1 line │ ┃ On-screen signals: off [ Turn on ] ⏺ Bash(bun test) │ ┃ Quick controls row above the prompt: on [ ⎿ 3 pass, 1 fail │ ┃ [ Play test chime ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chime │ ⎿ task-chime: Task chime settings opened. │ │ Chime: on [ Mute ] [ Settings ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Chime: on [ Mute ] [ Settings ]
Pane · Task chime
Task chime settings Sound: on [ Mute ] Volume: 75 [ Quieter ] [ Louder ] Loudness boost: 2 copies [ Fewer ] [ More ] Bluetooth wake-up delay: 1000 ms [ Shorter ] [ Longer ] On-screen signals: off [ Turn on ] Quick controls row above the prompt: on [ Hide ] [ Play test chime ]
README

task-chime

A Claude Code mod that plays a short sound when Claude finishes a task.

  • Plays on every finished answer in the main conversation.
  • Stays quiet for interrupted turns, errors, and subagents.
  • Optional on-screen signals (a banner above the prompt, a toast, a status line entry), off by default.

Requirements

Claude CodeA version that supports mods (plugins with function hooks)
Windows 10 or 11For the sound. Uses the built-in Windows PowerShell 5.1 and Windows media player. Nothing to install.
macOSClaude Code plays the clip itself. The PowerShell step is skipped automatically.
LinuxNo sound. Claude Code has no audio player there and the mod only supports Windows and macOS.

Install

For every session (recommended). Add this repository as a marketplace and install the plugin. It then loads in all your sessions, including the ones the Claude desktop app starts:

claude plugin marketplace add ialoni66/task-chime
claude plugin install task-chime@task-chime

Start a new session, or run /reload-plugins in an open one. To remove it later: claude plugin uninstall task-chime@task-chime.

I tested this route with a marketplace pointing at a local clone of this repository. Adding it straight from GitHub (the command above) uses the same mechanism but I have not run it from a second machine.

Just try it for one session:

git clone https://github.com/ialoni66/task-chime.git
claude --plugin-dir ./task-chime

Check that it loads:

claude plugin validate ./task-chime

What it does, and what it runs

On each finished answer, the mod:

  1. Asks Claude Code to play assets/notification.mp3 (this works on macOS).
  2. On Windows, starts scripts/chime.ps1 through PowerShell. The script has no window and no network access. It:
  3. streams about one second of silence first, so a sleeping Bluetooth headset can wake up before the chime,
  4. then plays the mp3, with a couple of copies layered for loudness.

If the script fails, you get a short toast naming the problem.

The mod needs permission to start a local PowerShell process. Read scripts/chime.ps1 first if you want to see exactly what it does. It is short.

Settings

Type /chime in Claude Code to open the settings pane. It shows each setting with buttons to change it, and a Play test chime button so you can hear the result straight away. Click a button, or give the pane focus (click it, or press ctrl+x then tab) and press its key:

KeyAction
mMute / unmute
1 / 2Quieter / louder
3 / 4Fewer / more loudness copies
5 / 6Shorter / longer Bluetooth wake-up delay
vTurn on-screen signals on / off
rShow / hide the quick controls row
tPlay test chime (plays even when muted)

The same settings are also rows in the /config menu in the terminal version of Claude Code. The desktop app has no /config, which is why the pane exists. Either way, changing a setting reloads the mod with the new value, and the pane reopens by itself.

SettingDefaultWhat it does
Mute the chimeoffTurns the sound off
Chime volume (0-100)75Volume of each copy of the chime
Loudness boost (1-4)2Identical copies played together. More is louder but can distort.
Bluetooth wake-up delay (ms)1000Silence played before the chime, 0 to 3000. Raise it if a Bluetooth headset still misses the start.
Show on-screen signalsoffShows a banner above the prompt, a toast, and a status line entry
Show the quick controls rowonA one-line row above the prompt: sound state, Mute and Settings buttons

Values outside a setting's range are refused. The settings are also stored in your Claude Code settings under pluginConfigs, keyed by the plugin name.

If you mute the chime and leave the on-screen signals off, nothing tells you a task finished. Turn on one of them.

To use your own sound, replace assets/notification.mp3.

Troubleshooting

  • No sound at first after being idle: Bluetooth headsets sleep. Raise the Bluetooth wake-up delay to 1500 or 2000 in /config.
  • Too quiet or too loud: change the volume or loudness boost in /config.
  • No sound at all: check the Windows volume mixer for an entry called "PowerShell", and confirm the right output device is selected.
  • A toast saying "Chime sound failed" or "could not start": PowerShell may be blocked by a security policy on your machine.
  • No sound right after editing the mod: reloading the mod can cut off the sound for that one reply. The next reply is normal.

Development

claude plugin validate .   # checks the manifest and what the code calls
claude plugin test .       # runs tests/register.test.tsx

The tests check the logic: which turns trigger the sound, the command and settings passed to PowerShell, and that nothing visual appears while the visuals are off. They do not play audio, so listening to it is still a manual check.

Project layout:

.claude-plugin/plugin.json   name, version, description
hooks/hooks.json             points to the code
hooks/register.tsx           the mod: hooks on turn.complete and the prompt band
scripts/chime.ps1            Windows sound player
assets/notification.mp3      the chime
types/index.d.ts             type contract for the mod's stored state
tests/register.test.tsx      tests

Credits and licence

The code is released under the MIT licence.

The sound assets/notification.mp3 is "New Notification 08" by Universfield. It is not covered by the MIT licence. Check the terms of the site you download it from before reusing or redistributing it, or swap in a sound of your own.

Source 2 files
hooks/register.tsx 224 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const SOUND = 'assets/notification.mp3'
5const SOUND_SCRIPT = 'scripts\\chime.ps1'
6const PANE = 'task-chime-settings'
7const BANNER_MS = 6000
8const STATUS_MS = 5000
9
10const isBannerShown = atom({ plugin: 'task-chime', key: 'isBannerShown' } as const, false)
11const isSettingsOpen = atom({ plugin: 'task-chime', key: 'isSettingsOpen' } as const, false)
12const note = atom({ plugin: 'task-chime', key: 'note' } as const, '')
13
14type Settings = { volumePercent: number; copies: number; leadMs: number }
15
16// A number setting, held to its range; anything else falls back to the default.
17const setting = (value: unknown, fallback: number, min: number, max: number) =>
18  typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
19
20const step = (value: number, delta: number, min: number, max: number) =>
21  Math.min(max, Math.max(min, value + delta))
22
23// Plays the chime now. macOS plays the clip through audio.play; Windows and Linux have no
24// player there, so on Windows scripts/chime.ps1 plays the mp3 with no window.
25const playChime = ($: any, s: Settings) => {
26  $.audio.play({ asset: SOUND }).catch((err: unknown) => {
27    $.ui.log(`task-chime: audio.play failed: ${String(err)}`, { to: 'debug' })
28  })
29
30  // spawn, not run: a spawned child and its loop outlive the hook's return, so the turn
31  // is never held up.
32  void (async () => {
33    // PowerShell and the Windows media player exist only on Windows (it sets OS=Windows_NT).
34    if ((await $.env.get('OS')) !== 'Windows_NT') return
35    try {
36      const child = $.process.spawn({
37        argv: [
38          'powershell.exe',
39          '-NoProfile',
40          '-NonInteractive',
41          '-WindowStyle',
42          'Hidden',
43          '-ExecutionPolicy',
44          'Bypass',
45          '-File',
46          `${$.plugin.root}\\${SOUND_SCRIPT}`,
47        ],
48        env: {
49          CHIME_SOUND: `${$.plugin.root}\\${SOUND.replace('/', '\\')}`,
50          CHIME_LEAD_MS: String(s.leadMs),
51          CHIME_COPIES: String(s.copies),
52          CHIME_VOLUME: String(s.volumePercent / 100),
53        },
54      })[Symbol.asyncIterator]()
55      let stderr = ''
56      for (;;) {
57        const next = await child.next()
58        if (next.done) {
59          if (next.value?.code !== 0) $.ui.toast(`Chime sound failed: ${stderr.slice(0, 120)}`)
60          break
61        }
62        if (next.value.stream === 'stderr') stderr += next.value.text
63      }
64    } catch (err) {
65      $.ui.toast(`Chime sound could not start: ${String(err).slice(0, 120)}`)
66    }
67  })()
68}
69
70// Changes one setting. Claude Code writes it and reloads the mod, which closes the pane;
71// session.start reopens it.
72const change = async ($: any, field: string, value: boolean | number) => {
73  const done = await $.config.set({ key: `task-chime.${field}`, value })
74  if (done.deny !== undefined) {
75    await update($, note, () => `Could not change that setting: ${done.deny}`)
76  }
77}
78
79// Opens the settings pane; used by /chime and by the quick row's Settings button.
80const openSettings = async ($: any) => {
81  await update($, isSettingsOpen, () => true)
82  await update($, note, () => '')
83  await $.ui.open({ id: PANE, title: 'Task chime' })
84}
85
86// The user's settings are declared as userConfig in plugin.json, so they also live in the
87// /config menu where there is one. The desktop app has none, so /chime opens a pane that
88// changes the same settings. A change reloads the mod, so they are read once here.
89export const register: Register = (on, options) => {
90  const isMuted = options.muted === true
91  const showVisuals = options.showVisuals === true
92  // On unless the person turned it off.
93  const showQuickRow = options.showQuickRow !== false
94  const settings: Settings = {
95    volumePercent: Math.round(setting(options.volume, 75, 0, 100)),
96    copies: Math.round(setting(options.copies, 2, 1, 4)),
97    leadMs: Math.round(setting(options.leadMs, 1000, 0, 3000)),
98  }
99
100  on('session.start', async ($, e, next) => {
101    // The banner flag and status line outlive a reload, but the timers that clear them
102    // do not. Clear any leftover when the mod loads (a reload fires session.start again).
103    await update($, isBannerShown, () => false)
104    $.ui.status(undefined)
105
106    await $.command.register({ name: 'chime', description: 'Open the task-chime settings pane' })
107    // A setting change reloads the mod and closes the pane; bring it back.
108    if (await read($, isSettingsOpen)) void $.ui.open({ id: PANE, title: 'Task chime' })
109
110    return next(e)
111  })
112
113  on('command.run', { command: 'chime' }, async $ => {
114    await openSettings($)
115
116    return { text: 'Task chime settings opened.' }
117  })
118
119  // Remember when the person closes the pane themselves, so a reload does not reopen it.
120  on('ui.close', async ($, e, next) => {
121    if (e.id === PANE && e.origin.kind !== 'unload') await update($, isSettingsOpen, () => false)
122    return next(e)
123  })
124
125  on('turn.complete', async ($, e, next) => {
126    const result = await next(e)
127
128    // Only the main conversation's finished answers, not subagents or interrupts.
129    if (e.agentId !== undefined || e.reason !== 'answer') return result
130
131    // In-app signals: the banner above the prompt, a toast and a status entry.
132    if (showVisuals) {
133      await update($, isBannerShown, () => true)
134      $.ui.toast('Task complete')
135      $.ui.status('Task complete')
136      // Clear them later; if the mod reloads meanwhile the wait is aborted, which is fine.
137      $.clock.sleep(BANNER_MS).then(() => update($, isBannerShown, () => false), () => {})
138      $.clock.sleep(STATUS_MS).then(() => $.ui.status(undefined), () => {})
139    }
140
141    if (!isMuted) playChime($, settings)
142
143    return result
144  })
145
146  // The row above the prompt: the "Task complete" banner while it is showing, and the
147  // quick controls (sound state, Mute, Settings) while that row is switched on.
148  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149    if (e.props.hasSurvey) return next(e)
150
151    const isBanner = showVisuals && (await read($, isBannerShown))
152    if (!isBanner && !showQuickRow) return next(e)
153
154    const { Box, Button, Text } = $.ui.resolve(e)
155
156    return (
157      <Box flexDirection="column">
158        {isBanner && (
159          <Box>
160            <Text bold>Task complete </Text>
161            <Button key="dismiss" label="Dismiss" onPress={() => update($, isBannerShown, () => false)} />
162          </Box>
163        )}
164        {showQuickRow && (
165          <Box>
166            <Text dimColor>{`Chime: ${isMuted ? 'muted' : 'on'}   `}</Text>
167            <Button key="quick-mute" label={isMuted ? 'Unmute' : 'Mute'} onPress={() => change($, 'muted', !isMuted)} />
168            <Text> </Text>
169            <Button key="quick-settings" label="Settings" onPress={() => openSettings($)} />
170          </Box>
171        )}
172      </Box>
173    )
174  })
175
176  // The settings pane. Every control is a Button with a letter or digit key, so it works
177  // from the keyboard once the pane has focus (ctrl+x tab, or a click).
178  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179    const { Box, Button, Text } = $.ui.resolve(e)
180    const message = await read($, note)
181    const { volumePercent, copies, leadMs } = settings
182
183    return (
184      <Box flexDirection="column">
185        <Text bold>Task chime settings</Text>
186        {message !== '' && <Text>{message}</Text>}
187        <Box>
188          <Text>{`Sound: ${isMuted ? 'muted' : 'on'}   `}</Text>
189          <Button key="mute" hotkey="m" label={isMuted ? 'Unmute' : 'Mute'} onPress={() => change($, 'muted', !isMuted)} />
190        </Box>
191        <Box>
192          <Text>{`Volume: ${volumePercent}   `}</Text>
193          <Button key="volume-down" hotkey="1" label="Quieter" onPress={() => change($, 'volume', step(volumePercent, -10, 0, 100))} />
194          <Text> </Text>
195          <Button key="volume-up" hotkey="2" label="Louder" onPress={() => change($, 'volume', step(volumePercent, 10, 0, 100))} />
196        </Box>
197        <Box>
198          <Text>{`Loudness boost: ${copies} ${copies === 1 ? 'copy' : 'copies'}   `}</Text>
199          <Button key="copies-down" hotkey="3" label="Fewer" onPress={() => change($, 'copies', step(copies, -1, 1, 4))} />
200          <Text> </Text>
201          <Button key="copies-up" hotkey="4" label="More" onPress={() => change($, 'copies', step(copies, 1, 1, 4))} />
202        </Box>
203        <Box>
204          <Text>{`Bluetooth wake-up delay: ${leadMs} ms   `}</Text>
205          <Button key="lead-down" hotkey="5" label="Shorter" onPress={() => change($, 'leadMs', step(leadMs, -250, 0, 3000))} />
206          <Text> </Text>
207          <Button key="lead-up" hotkey="6" label="Longer" onPress={() => change($, 'leadMs', step(leadMs, 250, 0, 3000))} />
208        </Box>
209        <Box>
210          <Text>{`On-screen signals: ${showVisuals ? 'on' : 'off'}   `}</Text>
211          <Button key="visuals" hotkey="v" label={showVisuals ? 'Turn off' : 'Turn on'} onPress={() => change($, 'showVisuals', !showVisuals)} />
212        </Box>
213        <Box>
214          <Text>{`Quick controls row above the prompt: ${showQuickRow ? 'on' : 'off'}   `}</Text>
215          <Button key="quick-row" hotkey="r" label={showQuickRow ? 'Hide' : 'Show'} onPress={() => change($, 'showQuickRow', !showQuickRow)} />
216        </Box>
217        <Box>
218          <Button key="test" hotkey="t" variant="primary" label="Play test chime" onPress={() => playChime($, settings)} />
219        </Box>
220      </Box>
221    )
222  })
223}
224
types/index.d.ts 8 lines
1export type BannerFlag = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'task-chime': { isBannerShown: BannerFlag; isSettingsOpen: boolean; note: string }
6  }
7}
8