All articles

Play a Sound When Claude Code Is Actually Done

Some Claude Code tasks take a while. You start one, and then you wait — watching the terminal, waiting for it to stop scrolling. It's a small thing, but doing it all day is tiring. Your eyes stay glued to a screen that mostly isn't changing, just so you know the moment it's your turn again.

The fix I settled on is simple: play a sound when Claude is done. Then I can look away entirely — read something, get a coffee — and my ears tell me when to come back. The catch, and the interesting part of this post, is making "done" mean actually done when there are parallel agents in flight.

The problem: watching is expensive

Waiting on a long task has a real cost:

  • It ties up your attention. You can't fully switch to something else, because you're half-watching for the task to finish. So you get the worst of both: you're not resting your eyes, and you're not really working on the other thing either.
  • You check too often. Every 20 seconds you flick back to the terminal. Nothing's changed. That constant flicking is what wears you down over a day.
  • "Done" is fuzzy with subagents. When a task spawns several agents that run in parallel, the main reply can go quiet while background agents are still working. So "it stopped printing" doesn't reliably mean "it's finished."

I wanted to stop looking and start listening — but only for a signal I could trust.

The building block: the Stop hook

Claude Code can run a shell command when events happen. These are hooks, and you configure them in settings.json. The one I care about here is Stop — it fires when Claude finishes a turn and hands control back to you.

The simplest possible version just plays a sound on stop:

"Stop": [
  { "hooks": [
    { "type": "command",
      "command": "paplay /usr/share/sounds/freedesktop/stereo/complete.oga" }
  ]}
]

(paplay is the PulseAudio player on Linux; on macOS you'd use afplay, and on Windows a PowerShell one-liner.) That's already useful for simple tasks. Play with it for five minutes and it feels great — until the day it lies to you.

Why the simple version isn't enough

The Stop event can fire while background work is still going. If your task kicked off parallel subagents, or started a long shell command in the background, the turn can "stop" in the foreground while those keep running. The naive hook plays its happy little sound, you come back — and Claude is still busy.

A "done" sound you can't trust is worse than none, because now you're second-guessing it and checking the screen anyway. So the real goal isn't "play a sound on stop." It's play a sound only when everything is truly finished.

That means answering two questions at stop time:

  1. Are any subagents still running?
  2. Are any background shell commands still running?

If either is yes, stay quiet. Only when both are no is it safe to chime.

Counting the agents with hooks

Subagents run inside the Claude process, so they don't show up as separate programs you can look for. I track them myself with a little counter — a temp file per session — kept up to date by three more hooks:

  • UserPromptSubmit resets the counter to 0 at the start of each turn.
  • PreToolUse on the Agent tool adds one every time an agent starts.
  • SubagentStop subtracts one every time an agent finishes.
"UserPromptSubmit": [{ "hooks": [{ "type": "command",
  "command": "sid=$(cat | jq -r '.session_id'); echo 0 > /tmp/claude-agents-$sid.count" }]}],

"PreToolUse": [{ "matcher": "Agent", "hooks": [{ "type": "command",
  "command": "sid=$(cat | jq -r '.session_id'); f=/tmp/claude-agents-$sid.count; echo $(( $(cat $f) + 1 )) > $f" }]}],

"SubagentStop": [{ "hooks": [{ "type": "command",
  "command": "sid=$(cat | jq -r '.session_id'); f=/tmp/claude-agents-$sid.count; echo $(( $(cat $f) - 1 )) > $f" }]}]

Now, at any moment, that file holds the number of agents still in flight. If it's above zero when the turn stops, there's more work coming.

The gatekeeper script

Instead of playing the sound directly, my Stop hook calls a small script that decides whether to play it. The script exits 0 for "go ahead" and 1 for "stay quiet," and the hook only plays the sound on a 0:

sid=$(cat | jq -r '.session_id')
echo idle > /tmp/claude-sl-$sid.state
~/.claude/hooks/notify-done.sh "$sid" && paplay .../complete.oga 2>/dev/null || true

The script itself runs the two checks. First, the agent counter:

n=$(cat "/tmp/claude-agents-$sid.count" 2>/dev/null)
[ "${n:-0}" -gt 0 ] && exit 1     # agents still running -> silent

Second, background shell jobs. Unlike agents, background bash commands are real child processes of the Claude CLI, so the script walks the process tree: it finds the Claude process, then looks for any leftover bash children (carefully skipping its own). If it finds one, work is still running:

# (simplified) for each process whose parent is the claude CLI:
#   if it's a bash shell that isn't us -> background job still alive -> exit 1

If both checks come back clear — counter at zero, no stray background shells — the script exits 0, and the sound plays. Now the chime means what I want it to mean: everything is done, come back.

The script also logs each decision to a temp file, so if it ever stays silent when I expected a sound, I can see why: "agent counter=2" or "background bash alive" tells me it was right to wait.

Beyond a sound

Once you have a trustworthy "done" signal, a sound is just one thing you can do with it. The same hook could pop a desktop notification (notify-send on Linux, osascript on macOS), flash the terminal, or send a message to your phone. I like a sound because it's ambient — I don't even have to be looking at the right screen.

A few caveats

  • Match the player to your OS. paplay is Linux/PulseAudio. macOS has afplay, Windows has PowerShell. Pick a real sound file that exists on your machine.
  • Headless or over SSH, there may be no audio. The || true at the end keeps a missing player from turning into an error — worth keeping.
  • Keep the checks cheap. This runs on every stop, so the script should be fast and make no network calls. Reading a counter file and scanning /proc is plenty quick.

Wrapping up

The default assumption is that you'll watch the screen to know when Claude is done. On long tasks, that's a tax on your eyes and your focus. A Stop hook that plays a sound lifts it — as long as the sound is honest. The whole trick is making "done" mean all agents and background jobs have finished, not just that the foreground went quiet. Track the agents with a counter, check for stray background shells, and only then chime. After that, you can look away and trust your ears.

Comments

Be the first to comment.