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 turn
  • PermissionRequest — 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.