세션이 열릴 때 도는 SessionStart를 봤으니 이번에는 반대쪽, Claude가 응답을 마쳤을 때 도는 Stop 훅입니다. 긴 리팩터링이나 빌드를 시켜 놓고 다른 창에서 일하다 보면 "끝났는지" 모르는 게 가장 큰 낭비인데, 알림음 하나면 해결됩니다. 이 글에서는 발화 시점과 입력 구조를 정확히 잡고, 알림음과 데스크톱 알림, 마지막 응답 로그까지 걸어 봅니다. 다음 글은 도구 실행 전후에 끼어드는 PreToolUse·PostToolUse입니다.
핵심 요약
Stop은 메인 에이전트가 응답을 끝낼 때마다, 즉 턴당 한 번 발화합니다. 사용자가Esc로 끊었을 때는 발화하지 않고, API 오류로 끝나면 대신StopFailure가 발화합니다.- matcher가 없는 이벤트입니다.
matcher를 적어도 무시됩니다. - 입력에
last_assistant_message가 들어 있어 트랜스크립트를 파싱하지 않고도 마지막 응답을 로그로 남길 수 있습니다. - 알림용 Stop 훅은 반드시
exit 0으로 끝내야 합니다. 종료 코드 2는 "멈추지 말고 계속 일하라"는 신호라서, 알림 스크립트가 2로 끝나면 Claude가 계속 돌아갑니다. - 입력을 기다리는 순간의 알림은
Notification훅, 서브에이전트 종료는SubagentStop이 담당합니다.
Stop 훅은 언제 발화하나
Claude가 한 턴의 응답을 완성하고 입력 대기로 돌아가는 순간입니다. 공식 문서는 "작업 완료 시에만이 아니라 응답을 완료할 때마다"라고 강조합니다. 즉 "네, 알겠습니다" 같은 짧은 답에도 울립니다. 우리 팀이 처음 알림음을 걸었을 때 짧은 대화마다 종소리가 나서 놀랐던 이유입니다. 필요하면 스크립트 안에서 last_assistant_message 길이나 background_tasks 유무를 보고 걸러야 합니다.
입력 JSON에는 공통 필드 외에 네 가지가 더 들어옵니다. stop_hook_active는 이미 Stop 훅이 한 번 차단해서 Claude가 이어 가는 중이면 true가 되고, last_assistant_message에는 방금 끝난 응답의 텍스트가 담깁니다. background_tasks는 진행 중인 백그라운드 작업 배열, session_crons는 세션 범위 예약 작업 배열입니다(둘 다 v2.1.145 이상).
background_tasks가 비어 있지 않으면 "끝났다"기보다 "백그라운드 작업이 깨울 때까지 잠시 멈춤"에 가깝습니다. 완료 알림은 이 배열이 비었을 때만 울리게 하면 오탐이 줄어듭니다.
기본 설정 형식
matcher 없이 핸들러만 적습니다.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.claude/hooks/on-stop.sh",
"timeout": 10
}
]
}
]
}
}
Stop은 사용자가 다음 프롬프트를 넣기 직전에 도는 훅이라 느리면 체감이 큽니다. 알림처럼 가벼운 작업은 timeout을 짧게 잡고, 무거운 후처리는 "async": true로 빼는 편이 좋습니다.
간단한 예로 동작 확인하기
먼저 마지막 응답을 로그 파일에 남기는 것으로 발화 여부를 확인합니다.
#!/bin/bash
# ~/.claude/hooks/on-stop.sh
INPUT=$(cat)
MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' | head -c 200 | tr '\n' ' ')
SID=$(echo "$INPUT" | jq -r '.session_id')
echo "$(date '+%F %T') [$SID] $MSG" >> "$HOME/.claude/stop.log"
exit 0
실행 권한을 주고 Claude Code를 다시 엽니다.
chmod +x ~/.claude/hooks/on-stop.sh
Claude에게 아무 말이나 건넨 뒤 로그를 확인합니다.
tail -3 ~/.claude/stop.log
줄이 이렇게 늘어나면 성공입니다.
2026-08-27 09:12:41 [c1a2…] 네, 알겠습니다.
2026-08-27 09:13:05 [c1a2…] README 상단에 설치 절차 3단계를 추가했습니다.
여기까지가 "증거"입니다. 이 로그는 나중에 일일 작업 보고서를 자동으로 만들 때 원재료가 되기도 합니다. git 커밋만으로는 하루를 못 잡지만, 턴마다 남은 요약은 그날 무엇을 했는지 그대로 보여 줍니다.
완료 시 알림음 울리기
macOS에는 시스템 사운드가 기본으로 들어 있습니다.
#!/bin/bash
# ~/.claude/hooks/on-stop.sh (알림음 추가)
INPUT=$(cat)
RUNNING=$(echo "$INPUT" | jq -r '.background_tasks // [] | length')
if [ "$RUNNING" = "0" ]; then
afplay /System/Library/Sounds/Glass.aiff >/dev/null 2>&1 &
fi
exit 0
&로 백그라운드에 던져 훅이 소리 길이만큼 기다리지 않게 하고, 마지막은 반드시 exit 0입니다. Windows(Git Bash)에서는 afplay 줄을 아래 중 하나로 바꿉니다.
powershell -c "[console]::beep(880,300)"
powershell -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\notify.wav').PlaySync()"
데스크톱 알림 표시하기
macOS는 osascript로 알림 센터에 띄웁니다.
osascript -e 'display notification "작업이 끝났습니다" with title "Claude Code"'
터미널 앱이 알림 권한을 요구할 수 있으니 처음 한 번은 시스템 설정에서 허용합니다. Windows는 PowerShell의 BurntToast 모듈을 쓰거나 msg * "Claude Code 완료" 정도로 간단히 시작할 수 있습니다. 터미널 자체 알림 기능을 쓰고 싶다면 스크립트 stdout에 terminalSequence 필드를 담은 JSON을 출력하는 방법도 있습니다(v2.1.141 이상). 훅에는 제어 터미널이 없어 /dev/tty에 직접 쓰면 실패하는데, 이 필드로 넘기면 Claude Code가 대신 출력해 주고 tmux 안에서도 동작합니다.
seq=$(printf '\033]777;notify;%s;%s\007' "Claude Code" "작업 완료")
jq -n --arg s "$seq" '{terminalSequence: $s}'
exit 0
알림음과 데스크톱 알림을 같이 받고 싶으면 한 스크립트에 두 줄을 넣거나, 핸들러 두 개로 나눕니다. 핸들러는 병렬로 실행되므로 순서 걱정은 없습니다.
Notification·SubagentStop 과는 무엇이 다른가
비슷해 보이는 세 이벤트의 역할이 다릅니다.
| 이벤트 | 발화 시점 | 쓰임 |
|---|---|---|
Stop |
메인 에이전트가 응답을 끝냈을 때 | 완료 알림, 턴 로그, "아직 끝나지 않았다"며 계속시키기 |
Notification |
Claude Code가 알림을 보낼 때. matcher permission_prompt(승인 대기), idle_prompt(입력 대기) 등 |
"지금 봐 달라"는 알림 |
SubagentStop |
서브에이전트가 응답을 끝냈을 때. matcher는 에이전트 타입 | 병렬 작업 개별 완료 처리 |
"끝났다"는 Stop, "기다린다"는 Notification, "부하 하나가 끝났다"는 SubagentStop으로 기억하면 됩니다. 권한 승인을 기다리는 중에는 Stop이 울리지 않으니, 자리를 비울 때는 Notification의 permission_prompt에도 같은 알림을 걸어 두는 것이 실전 구성입니다.
정리
Stop은 턴마다 한 번, 응답이 끝날 때 발화하며 사용자 중단 시에는 발화하지 않습니다.- matcher 없는 이벤트이고 입력의
last_assistant_message,background_tasks로 조건을 겁니다. - 알림 스크립트의 마지막은
exit 0. 2로 끝나면 Claude가 계속 일합니다. - macOS는
afplay와osascript, Windows는 PowerShell 대체 명령으로 같은 효과를 냅니다. - 승인 대기 알림은
Notification, 서브에이전트 완료는SubagentStop과 짝지어 씁니다.
자주 묻는 질문
Stop 훅은 언제 실행되나요?
메인 Claude가 한 턴의 응답을 마칠 때마다 실행됩니다. 긴 작업의 끝뿐 아니라 짧은 대답 뒤에도 울립니다. 사용자가 중간에 끊은 경우에는 실행되지 않고, API 오류로 턴이 끝나면 StopFailure 이벤트가 대신 발화합니다.
Stop 훅에도 matcher 를 지정해야 하나요?
아닙니다. Stop은 matcher를 지원하지 않는 이벤트라 matcher 필드를 적어도 무시됩니다. 조건을 걸고 싶으면 스크립트 안에서 입력 JSON의 last_assistant_message, background_tasks, stop_hook_active 같은 필드를 보고 분기합니다.
알림음과 데스크톱 알림을 동시에 받으려면 어떻게 하나요?
한 스크립트 안에 afplay ... &와 osascript -e 'display notification ...'을 함께 넣으면 됩니다. 핸들러를 둘로 나눠도 병렬로 실행되어 동시에 울립니다. 어느 쪽이든 마지막 줄은 exit 0이어야 Claude가 정상적으로 멈춥니다.
Notification 훅이나 SubagentStop 훅과는 무엇이 다른가요?
Notification은 Claude Code가 승인을 기다리거나(permission_prompt) 입력을 기다릴 때(idle_prompt) 발화하는 "봐 달라"는 알림이고, SubagentStop은 서브에이전트 하나가 끝날 때 발화합니다. Stop은 메인 에이전트의 턴이 끝났을 때만 발화합니다. 자리를 비우는 긴 작업이라면 Stop과 Notification 둘 다에 알림을 거는 것이 안전합니다.