Claude Code 업데이트 2.1.141: 훅 버그 3개 해결
Claude Code 업데이트 2.1.141에서 추가된 terminalSequence, args 배열, continueOnBlock을 살펴봅니다. 놓치던 데스크톱 알림과 셸 인용 오류, 끊기던 PostToolUse 검증을 실제 settings.json으로 해결합니다.

Claude Code 업데이트가 이어진 7일 동안, 1분기에 가장 크게 발목을 잡았던 훅 버그 3개가 조용히 해결됐습니다. 수정 사항을 확인한 다음 날 아침, 관리용 리포지토리에서 셸 우회 코드 3개를 지웠습니다.
Claude Code 업데이트로 실제 버그 3개가 사라진 일주일
2026년 5월 6일부터 13일까지 Anthropic은 Claude Code 2.1.132부터 2.1.141까지 연이어 릴리스했습니다. 변경 사항의 대부분은 훅 시스템에 집중됐습니다.
이 변화를 바로 알아챈 이유가 있습니다. 내 리포지토리에는 // TODO: remove when claude-code fixes this라는 주석이 붙은 임시방편이 3개 있었는데, 불과 일주일 만에 전부 삭제할 수 있었습니다.
실제로 치른 비용이 컸던 순서대로 정리하면 다음과 같습니다.
첫 번째는 데스크톱 알림 누락입니다. 긴 컨텍스트 압축이 끝나면 알림을 받으려고 Notification 훅에서 stdout으로 벨 문자를 내보냈습니다. Claude Code가 포그라운드 TTY를 점유할 때는 작동했지만, tmux 분할 창이나 VS Code 통합 터미널, 다른 창에 포커스가 있는 WezTerm 환경에서는 아무 소리 없이 실패했습니다. 2.1.141에서 추가된 terminalSequence가 이 문제를 해결했습니다.
두 번째는 셸 인용 처리의 불안정성이었습니다. 내 Stop 훅은 bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'"를 실행했는데, Claude가 반환한 문자열에 아포스트로피가 하나만 들어가도 reason 필드가 깨졌습니다. 2.1.139에서 args: string[] 실행 형식이 도입되며 해결됐습니다.
세 번째는 PostToolUse 거부가 Claude에게 돌아가 재시도를 유도하지 않고 턴 자체를 종료하던 문제입니다. 런타임이 block 결정을 치명적 오류로 처리하는 바람에 스키마 검증 훅을 아예 꺼야 했습니다. 2.1.139에 추가된 continueOnBlock은 차단을 재시도 신호로 바꾸고, 사유를 컨텍스트에 주입합니다.
마지막에는 전체 settings.json을 실었습니다. 중간에 군더더기는 없습니다.
90초 만에 이해하는 Claude Code 훅 구조
Claude Code 2.1.x는 9가지 훅 이벤트를 제공합니다. SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionEnd입니다. 각 훅은 런타임이 문서화된 환경과 stdin 페이로드를 넘겨 실행하는 명령입니다. 런타임은 훅의 stdout을 JSON으로 해석하며, 특정 필드가 동작을 제어합니다. decision은 허용·거부·차단을 정하고, reason은 Claude에게 되돌려 보내거나 사용자에게 표시할 문자열입니다. terminalSequence는 제어 TTY로 전달할 원시 바이트이며, 이 밖에도 이벤트별 필드가 몇 가지 있습니다.
핵심은 하나입니다. 런타임은 훅의 stdout을 최종 지시로 받아들입니다. 파싱하지 않는 필드는 아무 효과가 없고, 파싱하는 필드는 다음 턴에 Claude가 보게 될 내용을 바꿉니다. 훅의 작동 원리는 이것이 전부입니다.
terminalSequence로 TTY 점유 없이 Claude Code 알림 보내기
2.1.141 이전에 사용하던 Notification 훅은 다음과 같았습니다.
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}printf "\a"는 터미널 벨을 울리기 위한 코드였습니다. 하지만 실제로는 훅 프로세스가 상속받은 파일 디스크립터에 \a를 쓸 뿐입니다. Claude Code가 현재 포그라운드에 있지 않다면 그 대상은 사용자의 터미널이 아닙니다. Claude가 tmux의 2번 창에서 실행되고 내가 1번 창에서 편집할 때는 벨이 바깥쪽 터미널까지 도달하지 않았습니다. notify-send는 작동했지만 GNOME 알림 트레이에 들어가 버려 자주 놓쳤습니다.
2.1.141은 훅의 stdout JSON에 terminalSequence 필드를 추가했습니다. 런타임은 이 문자열을 훅 프로세스의 표준 입출력을 거치지 않고 제어 터미널 장치에 직접 씁니다. 업그레이드 후 설정은 다음과 같습니다.
{
"hooks": {
"Notification": [{
"command": "node hooks/notify.mjs"
}]
}
}import { execSync } from "node:child_process";
const payload = JSON.parse(
await new Response(process.stdin).text()
);
execSync(`notify-send "Claude" ${JSON.stringify(payload.message)}`);
process.stdout.write(JSON.stringify({
terminalSequence: ""
}));는 BEL입니다. 런타임이 이를 제어 TTY에 쓰기 때문에 Claude가 백그라운드 창에 있어도 바깥쪽 터미널에서 벨이 울립니다. 직접 테스트한 macOS Terminal, iTerm2, WezTerm, Alacritty에서는 모두 올바르게 작동했습니다. tmux도 BEL을 그대로 전달합니다. OSC 52를 위해서라도 tmux.conf에는 set -g allow-passthrough on을 유지하는 편이 좋습니다.
한 가지 예외가 있습니다. VS Code 통합 터미널은 기본 설정에서 BEL을 무시합니다. 벨을 울리려면 사용자 설정에서 "terminal.integrated.enableBell": true를 지정해야 합니다.
args: string[]로 훅 명령의 셸 이스케이프 없애기
Stop 훅은 한 턴이 끝날 때 실행됩니다. 나는 이전 실행 기록을 grep으로 검색할 수 있도록 세션 메타데이터를 Cloudflare D1 인스턴스에 기록하는 데 이 훅을 씁니다.
2.1.139 이전 설정은 다음과 같았습니다.
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}사용하기 시작한 첫 달에 이 설정에서 이스케이프 버그 3개를 겪었습니다.
첫째, $CLAUDE_STOP_REASON에 아포스트로피가 들어가면 작은따옴표로 감싼 인수가 중간에 닫히고 나머지가 셸 토큰으로 해석됐습니다. Claude가 user's request completed 같은 사유를 반환하면 훅이 비정상 종료되고 세션 로그도 사라졌습니다.
둘째, 툴 이름에 포함된 백틱입니다. Claude가 답변에 그렇게 적었다는 이유로 $CLAUDE_TOOL_NAME에 `bash` 문자열이 들어간 채 훅이 실행되면, 셸은 백틱 안의 내용을 서브셸로 실행하려 했습니다. 이 사례에서는 피해가 없었지만, 일반적으로는 생각만 해도 아찔한 동작입니다.
셋째, 사용자 프롬프트의 유니코드입니다. UTF-8은 대체로 bash -lc를 문제없이 왕복하지만, 특정 로케일 설정에서 일부 CJK 코드 포인트가 바이트를 조용히 유실했습니다.
2.1.139에서는 exec 형식 명령이 추가됐습니다. args를 문자열 배열로 넘기면 런타임이 중간에 셸을 두지 않고 명령을 직접 실행합니다.
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}런타임은 execve를 호출하기 전에 자체 환경에서 $CLAUDE_* 변수를 해석합니다. 셸도, 셸 보간도, 인용 처리도 없습니다. Claude가 만든 user's request completed 문자열은 변형 없이 하나의 argv[5] 항목으로 내 Node 스크립트에 전달됩니다.
모든 툴 호출 때 실행되는 PreToolUse 훅이라면 이 차이가 특히 중요합니다.
셸 형식을 유지해야 할 때도 있습니다. 파이프, 리디렉션, 글로빙이 필요한 경우입니다. 훅 항목 하나에서 args:[]와 command:""는 함께 쓸 수 없습니다. 따라서 node x.js | jq | tee log가 필요하다면 command:""를 유지하고 이스케이프 비용을 감수해야 합니다. 훅의 90%에는 exec 형식이 맞습니다.
continueOnBlock으로 PostToolUse 거부를 실제 재시도로 연결하기
가장 오래 기다린 수정 사항입니다. 나는 디스크에 쓰는 모든 툴 호출 결과를 검증하는 PostToolUse 훅을 운영합니다. Claude가 TypeScript 파일을 작성하면 훅이 해당 파일에 tsc --noEmit을 실행하고 타입 오류가 있을 때 거부합니다.
2.1.139 이전에는 거부 흐름이 제대로 작동하지 않았습니다.
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}validate-ts.mjs가 { "decision": "block", "reason": "tsc failed: ..." }를 반환하면 런타임은 턴을 종료했습니다. Claude는 사유를 받지 못했습니다. 사용자에게는 이해하기 어려운 "hook blocked the operation" 메시지만 표시됐고, 오류를 직접 붙여 넣어 다시 프롬프트해야 했습니다. 실제 세션 3개가 이런 식으로 끊긴 뒤에는 훅을 비활성화했습니다.
2.1.139에서는 훅별 설정인 continueOnBlock이 추가됐습니다.
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}이제 block 결정이 내려지면 reason 문자열이 툴 결과 오류 형태로 Claude의 컨텍스트에 돌아갑니다. Claude는 tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number'를 확인하고 다음 턴에 스스로 수정합니다. 반복 과정이 내부에서 진행되므로 사용자에게는 아무것도 보이지 않습니다.
maxAttempts는 강제 상한입니다. 이 값이 없으면 간헐적으로 중단되는 원격 API에 의존하는 검증처럼 결과가 비결정적인 작업이 무한히 재시도되며 컨텍스트를 소모할 수 있습니다. 나는 3으로 설정합니다. 3번 실패하면 훅은 하드 블록으로 전환해 사용자에게 문제를 표시합니다.
피해야 할 패턴도 있습니다. 결정이 시간에 따라 달라지는 훅에는 continueOnBlock을 켜지 마십시오. 배포가 진행되는 동안 쓰기를 거부하는 훅은 Claude가 재시도할 때도 배포 중이면 끝없이 반복됩니다. $CLAUDE_EFFORT를 기준으로 훅 실행을 제한하거나 훅 스크립트에 시도 횟수 카운터를 넣어야 합니다.
함께 추가된 두 변수: $CLAUDE_EFFORT와 CLAUDE_PROJECT_DIR
이 기간에는 환경 변수 2개도 조용히 추가됐는데, 둘 다 실용적입니다.
2.1.133은 훅 환경에 $CLAUDE_EFFORT를 주입하기 시작했습니다. 값은 현재 턴의 Claude effort level과 동일한 low, medium, high, xhigh입니다. 덕분에 프롬프트를 파싱하지 않고도 effort에 따라 훅 동작을 나눌 수 있습니다.
const effort = process.env.CLAUDE_EFFORT;
if (effort === "low" || effort === "medium") {
// skip expensive tsc check on quick edits
process.stdout.write(JSON.stringify({ decision: "allow" }));
process.exit(0);
}
// run full tsc --noEmit on high/xhigh빠른 편집에서 tsc를 생략하면 작업마다 약 800ms를 절약합니다. Claude가 열두 개의 파일을 작성하는 high 수준의 계획 턴에서는 전체 검사가 그대로 실행돼 실제 버그를 잡습니다.
2.1.139에서는 런타임이 실행하는 stdio MCP 서버의 환경에 CLAUDE_PROJECT_DIR가 추가됐습니다. 이전에는 stdio MCP 서버가 process.cwd()를 바탕으로 워크스페이스 루트를 추정해야 했고, 사용자가 하위 디렉터리에서 Claude Code를 실행하면 이 방식이 깨졌습니다. 이제 모든 MCP 서버가 process.env.CLAUDE_PROJECT_DIR를 읽어 워크스페이스 기준 경로를 정확히 해석할 수 있습니다.
MCP 서버를 유지보수한다면 매니페스트의 경로 해석 로직이 CLAUDE_PROJECT_DIR를 우선 사용하고, 구버전 클라이언트에서는 cwd()로 대체되도록 수정하십시오. 코드 두 줄로 버그 한 부류가 사라집니다.
실제로 사용 중인 settings.json 전체 설정
아래는 omidsaffari-admin에서 운영 중인 설정 블록이며, 일부만 가볍게 가렸습니다. 6개의 DO와 1개의 Workflow가 데스크톱 알림과 CI 게이팅을 위해 훅 출력에 의존합니다.
{
"model": "claude-sonnet-4-7-20260501",
"permissions": {
"edit": "ask"
},
"hooks": {
"SessionStart": [{
"args": ["node", "hooks/session-start.mjs"]
}],
"PreToolUse": [{
"matcher": "Bash",
"args": ["node", "hooks/gate-bash.mjs"]
}],
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}],
"Notification": [{
"args": ["node", "hooks/notify.mjs"]
}],
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}],
"PreCompact": [{
"args": ["node", "hooks/notify.mjs"]
}]
}
}{
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.141"
}
}설치하고 확인합니다.
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warnings핵심은 다음과 같습니다. continueOnBlock과 maxAttempts는 2.1.139에서 도입된 재시도 루프 조합입니다. 셸 기능이 필요하지 않은 모든 훅에는 args:[] 형식을 사용했으며, 이 설정에서는 모든 훅이 여기에 해당합니다. Notification과 PreCompact 훅은 같은 terminalSequence 출력을 통한 데스크톱 알림이 필요하므로 notify.mjs를 공유합니다.
Claude Code 업데이트 적용 체크리스트와 건너뛸 때
사전 준비부터 시작합니다. 현재 settings.json의 스냅샷을 만들고, 어떤 훅이 command:""를 쓰며 어떤 훅이 이미 args:[]를 쓰는지 확인하십시오. 연결된 이벤트 유형도 모두 목록으로 정리합니다.
한 번에 이벤트 유형 하나씩 마이그레이션하십시오. 각 변경을 적용한 뒤 24시간 동안 운영합니다. claude config doctor에서 훅 경고를 확인하고, 로그에서 decision과 reason 문자열을 grep으로 찾아 런타임이 예상대로 인식하는지 검증합니다.
권장 순서는 다음과 같습니다.
- 패키지를 2.1.141로 업그레이드합니다.
command:""훅 하나를args:[]로 바꾸고 실제로 실행되는지 확인합니다.Notification훅에terminalSequence를 추가하고 백그라운드 tmux 창에서 벨이 울리는지 확인합니다.- 가장 불편했던
PostToolUse훅에continueOnBlock을 추가합니다. 실제 세션 하나를 지켜보며 Claude가 사유를 받아 스스로 수정하는지 확인합니다. - 나머지 훅도 묶음 단위로
args:[]로 전환합니다.
상위 SDK가 아직 2.1.141을 검증하지 않아 관리형 설치 버전이 고정돼 있거나, 2.1.141에서 더 이상 권장하지 않는 훅 필드에 의존한다면 업그레이드를 건너뛰십시오. 이 글을 쓰는 시점 기준으로 5월 변경 사항 가운데 기존 필드를 깨뜨리는 것은 없었습니다. 그래도 팀에 배포하기 전에는 버전을 고정하고 브랜치에서 테스트해야 합니다.
6개 운영 에이전트에 적용한 버전 고정, 훅 전략, 프로젝트 스캐폴딩 플레이북 전체가 필요하다면 Claude Code + Codex 설정 체크리스트에서 처음부터 끝까지 확인할 수 있습니다. 같은 settings.json 패턴은 물론, 훅이 제어하는 에이전트 측 스캐폴딩(Workflows, DOs, Vectorize 바인딩)까지 담았습니다.
원격 샌드박스에서 이 에이전트들을 운영하는 방법을 다룬 개발 도구 글은 Cursor Cloud Agent 환경과 Cloudflare Workers 비교를 참고하십시오. 이 settings.json을 사용하는 운영 스택의 배경은 Cloudflare 100x 엔지니어 분석 글에 정리했습니다.
terminalSequence는 tmux에서도 작동하나요?
예. 다만 한 가지 조건이 있습니다. 런타임이 시퀀스를 제어 터미널 장치에 쓰면 tmux가 이를 바깥쪽 터미널로 전달합니다. tmux.conf에 set -g allow-passthrough on이 있거나 시퀀스가 일반 BEL()이면 됩니다. BEL은 조건 없이 통과하지만 OSC 시퀀스에는 passthrough를 켜야 합니다.
같은 훅 설정에서 args:[]와 command:''를 함께 쓸 수 있나요?
아니요. 훅 항목 하나에서는 서로 배타적입니다. 직접 실행할 명령에는 exec 형식(args:[])을, 파이프·리디렉션·글로빙이 필요한 명령에는 셸 형식(command:"")을 선택하십시오. 둘을 섞어야 한다면 exec 형식을 사용하는 래퍼 스크립트를 만들고 셸 기능은 그 안에 넣어야 합니다.
Claude가 같은 검증에 계속 걸리면 continueOnBlock이 무한 반복되나요?
maxAttempts를 설정했다면 그렇지 않습니다. 런타임은 해당 횟수에서 재시도를 멈추고 하드 블록으로 전환합니다. maxAttempts가 없으면 비결정적인 검증기가 컨텍스트를 계속 소모할 수 있으므로 반드시 지정해야 합니다. 타입 검사 형태의 검증에는 3이 합리적이며, 원격 상태에 의존하는 작업에는 1이 적절합니다.
$CLAUDE_EFFORT는 모든 훅 이벤트에서 사용할 수 있나요?
예. 2.1.133부터 런타임이 실행하는 모든 훅 환경에 주입됩니다. 값은 현재 턴의 effort level을 반영합니다. 따라서 SessionStart에서는 사용자가 시작할 때 선택한 effort를, PostToolUse에서는 툴이 실행될 당시 활성화된 effort를 받습니다.
2.1.138로 다운그레이드하면 무엇이 달라지나요?
terminalSequence, args:[], continueOnBlock, CLAUDE_PROJECT_DIR가 모두 조용히 작동을 멈춥니다. 런타임은 알 수 없는 JSON 필드를 무시하고 command:"" 파싱으로 돌아갑니다. 훅 자체는 계속 실행되지만 새 동작은 사용할 수 없습니다. 이 경로에 의존하기 전에는 브랜치에서 다운그레이드를 테스트하십시오.
2026년 9월 5일







