Хуки Claude Code в 2.1.141: три исправления вместо костылей
Три ошибки в хуках Claude Code исчезли за неделю: terminalSequence, args:string[] и continueOnBlock. Готовая настройка settings.json для продакшена.

В Q1 хуки Claude Code трижды подвели меня особенно болезненно. Все три ошибки незаметно исправили за 7 дней, а уже на следующее утро я удалил из админского репозитория три shell-обхода.
Хуки Claude Code: неделя, за которую исчезли три реальные ошибки
С 6 по 13 мая 2026 года Anthropic выпустила версии Claude Code от 2.1.132 до 2.1.141. Большая часть изменений пришлась на систему хуков.
Я обратил на это внимание из-за трёх костылей в своём репозитории. Рядом с каждым стоял комментарий // TODO: remove when claude-code fixes this, и за одну неделю я удалил их все.
Вот эти ошибки — в порядке нанесённого ущерба.
Первая — пропущенные уведомления на рабочем столе. Мой хук Notification отправлял системный сигнал в stdout, чтобы терминал оповещал меня после долгого уплотнения контекста. Пока Claude Code владел активным TTY, всё работало. В остальных случаях уведомление молча терялось: в разделённых окнах tmux, встроенных терминалах VS Code и панелях WezTerm, если фокус находился на соседней панели. Исправление появилось в 2.1.141 в виде terminalSequence.
Вторая — постоянно расползающееся экранирование shell-команды. Мой хук Stop запускал bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'", и стоило Claude вернуть строку с апострофом, как поле причины ломало команду. В 2.1.139 проблему решил exec-формат args: string[].
Третья — отклонения PostToolUse, которые завершали ход вместо того, чтобы вернуть управление Claude. Мне пришлось полностью отключить хук проверки схемы: среда выполнения воспринимала решение block как фатальную ошибку. В 2.1.139 появился continueOnBlock: теперь блокировка превращается в сигнал для повторной попытки, а её причина добавляется в контекст.
В конце приведён полный settings.json. Между делом — только необходимое.
Анатомия хуков за 90 секунд
Claude Code 2.1.x поддерживает девять событий хуков: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact и SessionEnd. Каждый хук — это команда, которую среда выполнения запускает с документированным окружением и данными в stdin. stdout хука разбирается как JSON, а отдельные поля управляют поведением среды: decision (allow/deny/block), reason (строка, которую возвращают Claude или показывают пользователю), terminalSequence (необработанные байты, отправляемые в управляющий TTY) и несколько полей для конкретных событий.
Главное: среда выполнения считает stdout хука источником истины. Поле, которое она не разбирает, бесполезно. Поле, которое она распознаёт, меняет то, что Claude увидит на следующем ходу. В этом и заключается вся механика.
terminalSequence: уведомления на рабочем столе без активного TTY
До 2.1.141 мой хук Notification выглядел так:
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}Команда printf "\a" должна была включать звуковой сигнал терминала. На практике она записывала \a в файловый дескриптор, унаследованный процессом хука, а это не пользовательский терминал — если только Claude Code в этот момент не работает на переднем плане. Когда Claude запущен в панели 2 tmux, а я редактирую файл в панели 1, сигнал до внешнего терминала не доходил. notify-send срабатывал, но уведомление оставалось в панели GNOME, где я его часто не замечал.
В 2.1.141 в JSON, который хук выводит в stdout, появилось поле terminalSequence. Среда выполнения берёт эту строку и напрямую записывает её в устройство управляющего терминала, обходя stdio процесса хука. Вот версия после обновления:
{
"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 в любом случае стоит оставить set -g allow-passthrough on в tmux.conf.
Есть один нюанс: встроенный терминал VS Code по умолчанию поглощает BEL. Чтобы сигнал срабатывал и там, добавьте "terminal.integrated.enableBell": true в пользовательские настройки.
args: string[] устранил экранирование shell-команд в моих хуках
Хук Stop срабатывает после завершения хода. Я использую его, чтобы записывать метаданные сессии в экземпляр Cloudflare D1, а затем искать по истории прошлых запусков.
Версия до 2.1.139:
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}За первый месяц я столкнулся здесь с тремя ошибками экранирования.
Апостроф в $CLAUDE_STOP_REASON закрывал аргумент в одинарных кавычках, после чего остаток строки разбирался как shell-токены. Когда Claude возвращал причину вроде user's request completed, хук падал, а запись о сессии терялась.
Обратные кавычки в именах инструментов. Если хук срабатывал при значении $CLAUDE_TOOL_NAME, содержащем строку `bash` — потому что Claude использовал её в ответе, — shell пытался выполнить содержимое обратных кавычек в подоболочке. В этом случае безобидно, но в целом пугающе.
Unicode в пользовательских промптах. Обычно UTF-8 без проблем проходит туда и обратно через bash -lc, однако при некоторых сочетаниях кодовых точек CJK и настроек локали байты незаметно терялись.
В 2.1.139 появился exec-формат команд. Передайте args как массив строк, и среда выполнения запустит команду напрямую, без промежуточного shell:
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}Перед вызовом execve среда выполнения сама подставляет переменные $CLAUDE_* из своего окружения. Нет shell — нет shell-интерполяции и проблем с кавычками. Строка user's request completed попадает в мой Node-скрипт единым элементом argv[5], в точности как её сформировал Claude.
Для хука PreToolUse, который запускается при каждом вызове инструмента, это существенно.
Когда всё же нужен shell-формат: если команда использует каналы, перенаправления или glob-паттерны. В одной записи хука 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" и был вынужден вручную отправлять новый промпт, вставив туда текст ошибки. После трёх рабочих сессий, сорванных таким образом, я отключил хук.
В 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 — будет бесконечно расходовать контекст на повторные попытки. Я использую значение три. После трёх неудач хук переходит к жёсткой блокировке и показывает ошибку пользователю.
Антипаттерн: не включайте continueOnBlock для хуков, чьи решения зависят от текущего времени. Хук, запрещающий запись во время окна развёртывания, уйдёт в бесконечный цикл, если при повторной попытке Claude развёртывание ещё продолжается. Либо проверяйте $CLAUDE_EFFORT, либо добавьте счётчик попыток в скрипт хука.
Бонусом: $CLAUDE_EFFORT и CLAUDE_PROJECT_DIR
В тот же период незаметно добавили две переменные окружения, и обе оказались действительно полезными.
Начиная с 2.1.133, $CLAUDE_EFFORT передаётся в окружение хуков. Возможные значения — low, medium, high и xhigh; они соответствуют уровню усилий Claude для текущего хода. Благодаря этому хук может выбирать ветку по уровню усилий, не разбирая промпт:
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На быстрых правках я экономлю около 800ms, пропуская tsc при низком уровне усилий. На планировочном ходу с уровнем high, когда Claude записывает дюжину файлов, полная проверка по-прежнему запускается и находит реальные ошибки.
В 2.1.139 переменная CLAUDE_PROJECT_DIR появилась в окружении stdio-серверов MCP, которые запускает среда выполнения. Раньше такие серверы определяли корень рабочего пространства через process.cwd(), и это ломалось, если пользователь запускал Claude Code из подкаталога. Теперь любой MCP-сервер может прочитать process.env.CLAUDE_PROJECT_DIR и корректно разрешить пути относительно рабочего пространства.
Если вы поддерживаете MCP-сервер, обновите логику разрешения путей в манифесте: используйте CLAUDE_PROJECT_DIR, а для старых клиентов оставьте запасной вариант с cwd(). Две строки кода — и целый класс ошибок исчезает.
Мой рабочий settings.json без сокращений
Это рабочий блок из omidsaffari-admin, лишь слегка отредактированный. Шесть DOs и один 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:[] используется для каждого хука, которому не требуются возможности shell, — то есть для всех хуков в этой конфигурации. Хуки Notification и PreCompact используют общий notify.mjs, поскольку обоим нужны одинаковые уведомления на рабочем столе через terminalSequence.
Чек-лист обновления и случаи, когда лучше пропустить версию
Перед началом сохраните снимок текущего settings.json. Отметьте, какие хуки используют command:"", а какие уже переведены на args:[]. Составьте список всех подключённых типов событий.
Переносите по одному типу событий за раз. После каждого изменения дайте конфигурации поработать 24 часа. Следите за предупреждениями хуков в claude config doctor и ищите в логах строки decision и reason, чтобы убедиться, что среда выполнения получает ожидаемые данные.
Я бы шёл в таком порядке:
- Обновить пакет до 2.1.141.
- Перевести один хук с
command:""наargs:[]. Проверить его срабатывание. - Добавить
terminalSequenceв хукNotification. Проверить сигнал из фоновой панели tmux. - Добавить
continueOnBlockв самый проблемный хукPostToolUse. Проследить за одной реальной сессией и убедиться, что Claude видит причину и самостоятельно исправляет ошибку. - Пакетно перевести остальные хуки на
args:[].
Не обновляйтесь, если используете управляемую установку, зафиксированную на версии родительского SDK, который ещё не проверен с 2.1.141, либо если зависите от поля хука, помеченного в 2.1.141 как устаревшее. На момент написания среди майских изменений не было ничего, что нарушало бы совместимость существующих полей, но перед внедрением для команды зафиксируйте версию и проверьте её в отдельной ветке.
Полный плейбук по фиксации версий, стратегии хуков и подготовке проектов для шести моих рабочих агентов собран в чек-листе настройки 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:[]), а если нужны каналы, перенаправления или glob-паттерны — shell-формат (command:""). Если требуется совместить оба подхода, создайте скрипт-обёртку: вызывайте его через exec-формат, а shell-возможности реализуйте внутри.
Будет ли continueOnBlock повторяться бесконечно, если Claude снова и снова не проходит одну проверку?
Нет, если задать maxAttempts. Среда выполнения ограничит число повторов этим значением, а затем перейдёт к жёсткой блокировке. Без maxAttempts недетерминированный валидатор действительно может впустую расходовать контекст. Для проверок типов разумное значение по умолчанию — три; для проверок, зависящих от удалённого состояния, — один.
Доступна ли $CLAUDE_EFFORT во всех событиях хуков?
Да. Начиная с 2.1.133 эта переменная передаётся в окружение каждого хука, который запускает среда выполнения. Значение соответствует уровню усилий для текущего хода: SessionStart получает уровень, с которым пользователь начал работу, а PostToolUse — уровень, активный на момент вызова инструмента.
Что сломается после отката до 2.1.138?
terminalSequence, args:[], continueOnBlock и CLAUDE_PROJECT_DIR молча перестанут работать. Среда выполнения игнорирует неизвестные поля JSON и возвращается к разбору command:"". Хуки продолжат запускаться, но новые возможности исчезнут. Прежде чем полагаться на откат, проверьте его в отдельной ветке.
5 сент. 2026 г.







