Hearing When Claude Code Finishes¶
I usually have Claude Code running in a terminal that I keep minimized while doing something else. The problem is obvious in hindsight: I'd come back ten minutes later only to find Claude had been sitting there the whole time, waiting for me to approve a permission prompt or just done with the task.
The fix is small. A bash script that plays a macOS system sound, plus two Claude Code hooks. That's it.
The script¶
It lives at scripts/play in my dotfiles and gets symlinked to ~/bin/play by the install script. The whole thing is around 60 lines of bash wrapping afplay:
#!/bin/bash
this_script_path="$(realpath "${0}")"
this_script_name="${this_script_path##*/}"
sounds_dir="/System/Library/Sounds"
show_usage() {
cat <<EOF
Play a notification sound.
Default sounds directory: ${sounds_dir}
Options:
-h, --help Show this help
--list List available sound names
Examples:
${this_script_name}
${this_script_name} Glass
${this_script_name} Glass.aiff
${this_script_name} /path/to/sound.aiff
EOF
}
play_sound() {
afplay "${1}"
}
case "${1}" in
-h | --help)
show_usage
exit 0
;;
--list)
ls "${sounds_dir}"/*.aiff | xargs -I{} basename {} .aiff
exit 0
;;
"")
selected="$(ls "${sounds_dir}"/*.aiff | xargs -I{} basename {} .aiff | fzf)"
[[ -z "${selected}" ]] && exit 0
play_sound "${sounds_dir}/${selected}.aiff"
;;
*)
if [[ "${1}" == /* || "${1}" == */* ]]; then
sound="${1}"
elif [[ "${1}" == *.aiff ]]; then
sound="${sounds_dir}/${1}"
else
sound="${sounds_dir}/${1}.aiff"
fi
play_sound "${sound}"
;;
esac
play Glass plays /System/Library/Sounds/Glass.aiff. play /path/to/file.aiff plays anything you point at it. Run play with no arguments and it opens an fzf picker over the system sounds so you can browse them before deciding which one you actually want.
Wiring it into Claude Code¶
Claude Code has a hooks system in ~/.claude/settings.json. Two events matter for this:
Stop— fires when Claude finishes its turnPermissionRequest— fires when Claude is asking to run a tool that needs approval
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "play Glass",
"async": true
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "play Ping",
"async": true
}
]
}
]
}
}
Different sounds for different events on purpose. Glass is a soft chime — Claude is done, no rush. Ping is sharper — Claude is blocked waiting on me. After a few days I started recognizing them without thinking, the same way you learn the difference between a Slack mention and a regular notification.
async: true matters. Without it, the hook blocks Claude until afplay finishes, which adds noticeable latency to every turn.
Why this works¶
The whole thing is maybe 70 lines of code split between the script and the JSON. No daemon, no menu bar app, no notification framework. macOS already ships dozens of .aiff files in /System/Library/Sounds/, and afplay plays them. Claude Code already runs arbitrary commands on hooks. The script just glues them together.
The unexpected payoff is that I stopped checking the terminal compulsively. I trust the sound now. If I don't hear it, Claude is still working and I keep doing whatever else I was doing. If I hear Ping, I switch back. That's a small habit change but it adds up over a day of pair-programming with an agent.