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:
- Are any subagents still running?
- 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:
UserPromptSubmitresets the counter to0at the start of each turn.PreToolUseon theAgenttool adds one every time an agent starts.SubagentStopsubtracts 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.
paplayis Linux/PulseAudio. macOS hasafplay, Windows has PowerShell. Pick a real sound file that exists on your machine. - Headless or over SSH, there may be no audio. The
|| trueat 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
/procis 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.