SLOPSHOPPER

done-sound

Plays a sound when a long turn finishes.

processaudio
v0.2.0no licenseupdated 2026-10-25pongsixtyfour/mods/plugins/done-sound
A shopper browsing a rack in a slop shop
README

done-sound

A Claude Code mod that plays a sound when a long turn finishes, so you can look away while Claude works.

Behaviour

It plays after a turn only when all of these hold:

  • the turn ran for 15 seconds or more
  • it is the main session's turn, not a subagent's
  • you did not interrupt it

The clip is a short chime (sounds/done.wav, from the Cozy SFX pack).

Platforms

SystemPlayerStatus
macOSthe engine's own (afplay), at the configured gain (default 3)tested
Linuxthe first that works of paplay, aplay, ffplaynot tested on a real machine
Windowspowershell.exe with Media.SoundPlayernot tested on a real machine

On macOS nothing else is needed. On Linux, install one of the three players if none is there (paplay comes with PulseAudio and PipeWire, aplay with ALSA, ffplay with ffmpeg). Windows needs nothing extra.

The mod finds the system by running uname, so it needs the terminal (CLI) version of Claude Code, where mods can run commands. If no player works, it stays silent and does not fail. The Linux and Windows players use the system volume, so the clip is quieter there than on macOS, where gain 3 boosts it. This applies to terminal sessions only; the desktop app and VS Code are not covered.

Install

At the prompt of a Claude Code terminal session:

/plugin install done-sound --marketplace pongsixtyfour/mods

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install done-sound --marketplace pongsixtyfour/mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with pongsixtyfour/mods, then Add plugin.

Configure

Volume is a plugin option. Change gain (0 to 4, default 3, macOS only) in /config, or under pluginConfigs in settings.json.

To change the shortest turn that earns a sound, edit MIN_MS at the top of hooks/register.ts (milliseconds, default 15_000).

To change the sound, replace sounds/done.wav with a .wav file (the Windows player only plays .wav) (keep the name, or update the path in register.ts).

Develop

claude plugin validate .
claude plugin test .

The tests cover when it plays (a long turn plays; a short, interrupted or subagent turn stays silent) and which player each system uses, with the commands faked.

Source 1 files
hooks/register.ts 64 lines
1import type { Register } from 'claude-code'
2
3const MIN_MS = 15_000
4const DEFAULT_GAIN = 3
5const CLIP = 'sounds/done.wav'
6
7// Where the engine's own player (afplay) is missing, run one of these by argv, first that works.
8const LINUX_PLAYERS = [
9  ['paplay'],
10  ['aplay', '-q'],
11  ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet'],
12]
13const WINDOWS_PLAYER = [
14  'powershell.exe',
15  '-NoProfile',
16  '-Command',
17  '(New-Object Media.SoundPlayer $env:DONE_SOUND).PlaySync()',
18]
19
20type Platform = 'mac' | 'linux' | 'windows'
21
22// `uname` answers on macOS and Linux; where it cannot start, this is Windows.
23function detect($: any): Promise<Platform> {
24  return $.process.run(['uname', '-s']).then(
25    (r: { stdout: string }) => (r.stdout.trim() === 'Darwin' ? 'mac' : 'linux'),
26    () => 'windows',
27  )
28}
29
30async function tryRun($: any, argv: string[], env?: Record<string, string>): Promise<boolean> {
31  const r = await $.process
32    .run(argv, { timeoutMs: 10_000, ...(env ? { env } : {}) })
33    .catch(() => undefined)
34  return r?.exitCode === 0
35}
36
37async function chime($: any, gain: number): Promise<void> {
38  const where = await detect($)
39  if (where === 'mac') {
40    await $.audio.play({ asset: CLIP }, { gain })
41    return
42  }
43  const file = `${$.plugin.root}/${CLIP}`
44  if (where === 'windows') {
45    await tryRun($, WINDOWS_PLAYER, { DONE_SOUND: file.replace(/\//g, '\\') })
46    return
47  }
48  for (const player of LINUX_PLAYERS) {
49    if (await tryRun($, [...player, file])) return
50  }
51}
52
53export const register: Register = (on, options) => {
54  const gain = typeof options.gain === 'number' ? options.gain : DEFAULT_GAIN
55  on('turn.complete', async ($, e, next) => {
56    const ran = await next(e)
57    const isLong = e.durationMs >= MIN_MS
58    if (e.agentId === undefined && e.reason !== 'aborted' && isLong) {
59      await chime($, gain).catch(() => {})
60    }
61    return ran
62  })
63}
64