Хуки Claude Code
Привет!
Claude Code можно долго настраивать текстом. Писать инструкции в файле проекта, заводить правила, объяснять агенту, чего делать не надо. Работает это ровно до того момента, пока модель считает нужным вас слушать.
Инструкция — просьба. Хук — код, который выполнится всегда, что бы модель ни решила в этот раз.
Разница практическая. Написали в инструкциях «форматируй код после правок» — иногда отформатирует. Повесили форматтер на событие после правки файла — форматирование происходит каждый раз, даже когда агент о нём не думал.
В этой статье разбираю хуки целиком: устройство, все события, что можно вернуть и как это меняет поведение агента. А потом показываю три хука, которые у меня работают каждый день: звук, контроль лимитов подписки и автоматический журнал работы.
Чем хук отличается от правила, скила и подагента
Хук — команда, которую Claude Code запускает сам в определённые моменты своей жизни. Перед вызовом инструмента, после правки файла, при старте сессии, когда вы отправили запрос, когда ход закончился.
Команде на вход подаётся JSON с описанием события. Она отвечает кодом выхода и, если нужно, своим JSON. Ответ может остановить действие, подменить аргументы вызова, добавить текст в контекст модели или не менять ничего — тогда хук просто наблюдатель.
Чтобы не путаться в способах расширения:
- Инструкции и правила — текст в контексте. Модель его читает и обычно выполняет, но это текст, конкурирующий за внимание с остальной задачей.
- Скилы — знание по требованию. Подгружаются, когда задача им соответствует. Тоже текст, только экономнее.
- Подагенты — отдельные контексты под шумные задачи, чтобы разведка не забивала основной диалог.
- MCP-серверы — новые инструменты, которыми модель может воспользоваться. Если захочет.
- Хуки — детерминированный код в обход модели. Не «может воспользоваться», а «выполнится».
Правильная связка почти всегда такая: хук добывает факты и держит гарантии, правило объясняет модели, что с этими фактами делать. На примере контроля бюджета ниже видно, что по отдельности обе половины бесполезны.
Первый хук за пять минут
Начнём с простого: уведомления, когда агент ждёт вашего внимания.
Открываем файл настроек (глобальные лежат в домашнем каталоге, настройки проекта — в самом проекте) и добавляем блок:
{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Агент ждёт вас'" } ] } ] }}Структура вложенная и в первый раз выглядит избыточно. Но она логична: hooks — объект, где ключ это имя события; внутри список групп; у группы есть фильтр matcher и свой список обработчиков. Несколько обработчиков в одной группе выполняются параллельно.
На macOS вместо notify-send подойдёт osascript, на Windows — вызов PowerShell.
Проверить, что хук зарегистрирован, можно командой /hooks прямо в сессии. Она показывает все настроенные хуки с разбивкой по событиям и говорит, из какого файла настроек каждый пришёл. Меню только для чтения, редактировать хуки нужно в JSON.
Самая частая ошибка первой настройки: файл уже содержит ключ hooks, и новое событие пишут вместо существующих, а не рядом с ними.
Где живут настройки
Место, куда вы положили хук, определяет область его действия:
| Где | Область | Попадает в репозиторий |
|---|---|---|
| Настройки пользователя в домашнем каталоге | Все ваши проекты | Нет |
| Настройки проекта | Один проект | Да, если закоммитить |
| Локальные настройки проекта | Один проект | Нет, игнорируются гитом |
| Корпоративные управляемые политики | Вся организация | Да, задаёт администратор |
| Плагин | Пока плагин включён | Да, внутри плагина |
| Заголовок скила или агента | Пока компонент активен | Да, внутри компонента |
Уровни складываются, а не перекрывают друг друга. Хук из пользовательских настроек и хук из проектных на одно событие выполнятся оба. Одинаковый обработчик, описанный в нескольких файлах настроек, выполнится один раз: дедупликация есть. А вот копии из плагинов и скилов считаются отдельными, их не дедуплицируют.
Выключить всё разом можно настройкой disableAllHooks. Действует она в пределах своего уровня: хуки из корпоративных политик так не отключить. Есть и обратная настройка, allowManagedHooksOnly, которой организация запрещает пользовательские, проектные и плагинные хуки целиком.
Правки файлов настроек подхватываются на лету, перезапускать сессию не нужно. Если хук не появился в меню через несколько секунд, ищите ошибку в JSON: висячие запятые и комментарии там запрещены.
Как хук общается с Claude Code
Обмен предельно простой. JSON на вход, код выхода и, по желанию, JSON на выход.
Что приходит на вход
При каждом событии в стандартный ввод хука прилетает объект. Общие поля есть у всех событий:
{ "session_id": "abc123", "transcript_path": "/путь/до/транскрипта.jsonl", "cwd": "/текущий/каталог", "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": { "command": "npm test" }}Дальше каждое событие добавляет своё. Перед вызовом инструмента приходят его имя и аргументы, при отправке запроса — текст запроса, при старте сессии — источник запуска, при завершении хода — последний ответ ассистента.
Отдельно запомните поле с путём до транскрипта. Это файл, куда пишется вся сессия построчно. Через него хук узнаёт то, чего в самом событии нет: какой моделью шла работа, сколько токенов ушло на последний запрос, какие инструменты вызывались за ход. Оба сложных хука ниже этим пользуются.
Ещё хук получает переменные окружения, в том числе корень проекта. Это избавляет от возни с относительными путями.
Что можно вернуть
Первый способ — код выхода:
- 0 — возражений нет, действие продолжается. Для отправки запроса, разворачивания команды и старта сессии всё, что хук напечатал в стандартный вывод, добавляется в контекст модели.
- 2 — блокировать. То, что хук написал в поток ошибок, получит модель как объяснение и сможет поправиться. Блокировать умеют не все события: старт сессии и уведомления, например, не блокируются, там сообщение просто покажут пользователю.
- любой другой — ошибка, но не блокирующая. Действие продолжится, а в транскрипте появится строчка про сбой хука.
Минимальный блокирующий хук на shell:
#!/bin/bashINPUT=$(cat)FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in *.env|*package-lock.json|*.git/*) echo "Правка $FILE запрещена политикой проекта" >&2 exit 2 ;;esacexit 0Второй способ — структурный ответ: выйти с нулём и напечатать JSON. Так можно не просто запретить, а объяснить, переспросить пользователя или подменить данные:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "В этом проекте вместо grep используется rg" }}Значения решения: allow пропускает диалог подтверждения, deny отменяет вызов и передаёт причину модели, ask показывает пользователю обычный запрос разрешения. В неинтерактивном режиме есть ещё defer, оставляющее вызов подвешенным для внешней обёртки.
Смешивать способы нельзя. При коде выхода 2 ваш JSON игнорируется целиком.
Важная асимметрия: хук может ужесточить правила, но не может их ослабить. Запрет из настроек сильнее, чем allow из хука. Зато deny из хука работает даже в режиме, где подтверждения отключены совсем. Это и делает хуки пригодными для политик, которые пользователь не должен обходить сменой режима.
Для добавления контекста есть отдельное поле:
{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "Текущая ветка: release-42. Заморозка деплоя до пятницы." }}Тонкость, на которой легко потерять полдня. Поле обязано лежать внутри вложенного объекта. Написанное на верхнем уровне, оно молча игнорируется: ошибки не будет, просто ничего не произойдёт.
Остальные события используют свои схемы решений. После вызова инструмента и на завершении хода это поле decision со значением block, при запросе разрешения — вложенный объект с полем поведения. Общие для всех поля тоже есть: continue со значением «ложь» останавливает агента совсем, systemMessage показывает предупреждение пользователю, suppressOutput прячет вывод хука из транскрипта.
Размер ответа ограничен десятью тысячами символов. Всё, что длиннее, сохраняется в файл, а в контекст попадает начало и путь. Так что хук, вываливающий гигантский лог, потеряет большую его часть.
Все события
Событий на момент написания больше тридцати. Списком они не запоминаются, поэтому вот по группам.
Жизнь сессии:
| Событие | Когда |
|---|---|
SessionStart | Сессия началась или возобновилась |
Setup | Разовая подготовка при запуске с флагами инициализации, для CI и скриптов |
SessionEnd | Сессия завершилась |
Ход диалога:
| Событие | Когда |
|---|---|
UserPromptSubmit | Запрос отправлен, но модель его ещё не видела |
UserPromptExpansion | Введённая команда развернулась в запрос; можно заблокировать |
Stop | Модель закончила отвечать |
StopFailure | Ход оборвался из-за ошибки API; вывод и код возврата игнорируются |
MessageDisplay | Текст ответа выводится на экран; можно подменить показ, не трогая транскрипт |
Цикл инструментов:
| Событие | Когда |
|---|---|
PreToolUse | Перед вызовом инструмента; можно заблокировать или подменить аргументы |
PermissionRequest | Нужно решение о разрешении, можно ответить за пользователя |
PermissionDenied | Вызов отклонён автоматическим классификатором |
PostToolUse | Инструмент отработал успешно |
PostToolUseFailure | Инструмент упал |
PostToolBatch | Разрешилась целая пачка параллельных вызовов |
Подагенты и задачи:
| Событие | Когда |
|---|---|
SubagentStart | Подагент запущен |
SubagentStop | Подагент закончил |
TeammateIdle | Участник команды агентов уходит в простой |
TaskCreated | Создаётся задача |
TaskCompleted | Задача помечается выполненной |
Файлы, каталоги и конфигурация:
| Событие | Когда |
|---|---|
InstructionsLoaded | Загружен файл инструкций или правил |
ConfigChange | Файл настроек изменился прямо во время сессии |
CwdChanged | Сменился рабочий каталог |
DirectoryAdded | В сессию добавили ещё один каталог |
FileChanged | Изменился файл, за которым вы просили следить |
WorktreeCreate / WorktreeRemove | Создаётся или удаляется рабочее дерево гита |
Контекст и внешний мир:
| Событие | Когда |
|---|---|
PreCompact / PostCompact | До и после сжатия контекста |
Notification | Claude Code показывает уведомление |
Elicitation / ElicitationResult | MCP-сервер запрашивает ввод у пользователя и получает ответ |
Половина списка вам никогда не понадобится, и это нормально. На практике почти всё полезное строится на восьми событиях: старт сессии, отправка запроса, до и после вызова инструмента, уведомление, конец хода, запуск и остановка подагента.
Три штуки из списка редко замечают, а зря.
Событие изменения конфигурации позволяет журналировать и даже запрещать правки настроек посреди сессии. Полезно там, где важна прослеживаемость.
Событие смены рабочего каталога закрывает старую боль с переменными окружения. Инструменты вроде direnv переключают их в вашей оболочке, но не в оболочке агента. Этот хук вместе с событием старта сессии чинит расхождение:
{ "hooks": { "SessionStart": [ { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] } ], "CwdChanged": [ { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] } ] }}А событие загрузки инструкций даёт точку, где видно, какие именно правила реально доехали до контекста. Незаменимо, когда правило с масками путей почему-то не срабатывает.
Фильтры: matcher и if
Без фильтра хук срабатывает на каждое появление своего события. matcher сужает это на уровне группы:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" } ] } ] }}Форматтер запустится после правки файла и не запустится после чтения или вызова команды. Вертикальная черта разделяет альтернативы, в свежих версиях так же работает запятая. Пустая строка или отсутствие поля означают «на всё».
Сравнение регистрозависимое. Это самая частая причина молчащего хука, проверяйте первым делом.
У каждого события matcher фильтрует своё поле. У инструментальных событий имя инструмента, у старта сессии способ запуска (обычный старт, возобновление, очистка, сжатие, ответвление), у завершения причину, у уведомления тип уведомления, у запуска подагента тип агента, у сжатия ручное оно или автоматическое, у обрыва хода вид ошибки API. Часть событий фильтров не поддерживает вовсе и срабатывает всегда.
Инструменты MCP именуются по схеме с двойными подчёркиваниями, где в середине стоит имя сервера. Регулярным выражением легко поймать все инструменты одного сервера или все операции записи по всем серверам сразу.
Поле if — более тонкий фильтр, доступный только на инструментальных событиях. Оно использует синтаксис правил разрешений и смотрит не только на имя инструмента, но и на аргументы:
{ "type": "command", "if": "Bash(git *)", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"}Процесс хука тогда вообще не порождается, если команда не подходит под шаблон. Экономия заметная, когда хук висит на каждом вызове командной строки. Разбор при этом умный: составные команды через двойной амперсанд проверяются по частям, вложенные подстановки тоже.
Но он принципиально «на удачу». Если команду не удалось разобрать, фильтр пропускает вызов к хуку на всякий случай. Поэтому жёсткие запреты делаются системой разрешений, а не фильтром хука.
Пять типов обработчиков
Большинство хуков — это команда. Но типов пять, и остальные четыре решают задачи, для которых команда неудобна.
Команда. Запускает исполняемый файл или строку в оболочке. Основной рабочий вариант.
HTTP. Отправляет то же событие POST-запросом на адрес, а решение читает из тела ответа. Удобно, когда логику держит общий сервис, например командный аудит вызовов инструментов:
{ "type": "http", "url": "http://localhost:8080/hooks/tool-use", "headers": { "Authorization": "Bearer $MY_TOKEN" }, "allowedEnvVars": ["MY_TOKEN"]}Подстановка переменных окружения в заголовки работает только для явно перечисленных имён, остальные останутся пустыми. Заблокировать действие кодом HTTP нельзя: нужен успешный ответ с тем же JSON-решением, что и у команды.
Инструмент MCP. Вызывает инструмент уже подключённого сервера и разбирает его ответ как решение.
Промпт. Решение принимает маленькая модель. Вы пишете вопрос, ей передаётся событие, обратно приходит «да/нет» с причиной. Выход для случаев, где нужно суждение, а не детерминированный признак:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Проверь, все ли задачи из запроса выполнены. Если нет — ответь {\"ok\": false, \"reason\": \"что осталось\"}." } ] } ] }}Отрицательный ответ на завершении хода возвращается модели как инструкция, и она продолжает работать. По умолчанию используется быстрая дешёвая модель, но её можно поменять.
Агент. То же самое, но решение принимает полноценный подагент с инструментами. Он может прочитать файлы, запустить тесты и только потом ответить. Дороже и медленнее, зато проверяет реальное состояние проекта:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "agent", "prompt": "Проверь, что юнит-тесты проходят. Запусти их и посмотри результат. $ARGUMENTS", "timeout": 120 } ] } ] }}Тип экспериментальный, для production-процессов надёжнее команда.
Правило выбора простое. Данных события хватает — промпт. Нужно посмотреть на код и что-то запустить — агент. Всё остальное — команда.
Как именно запускается команда
Тут развилка, объясняющая половину странных ошибок на Windows.
Есть у обработчика поле args — работает exec-форма. command считается путём к исполняемому файлу, аргументы передаются ровно как написаны, оболочки нет вообще. Кавычки, доллары и обратные кавычки проходят насквозь без интерпретации.
Нет поля args — работает shell-форма. Строка отдаётся оболочке, которая раскроет переменные, обработает конвейеры и подстановки. На macOS и Linux это sh, на Windows — Git Bash, а если его нет, то PowerShell. Оболочку можно назначить явно.
На Windows exec-форма требует настоящий исполняемый файл. Файлы-обёртки, которые npm раскладывает для своих утилит, исполняемыми не являются и напрямую не запускаются. Либо зовите через node с путём до скрипта, либо переходите на shell-форму.
Таймауты, фон и лимиты
По умолчанию у команды, HTTP и MCP-обработчика есть десять минут. Но событие отправки запроса урезает их до тридцати секунд, показ сообщения до десяти, а всем хукам на завершении сессии выделено полторы секунды на всех. Промпт-хук ждёт тридцать секунд, агентный минуту.
Долгую работу вешают в фон флагом async. Такой хук не задерживает сессию, но и решать ничего не может: его ответ уже никому не нужен.
Есть промежуточный вариант, asyncRewake. Хук работает в фоне, а если завершится с кодом 2, агент будет разбужен и получит его сообщение. Единственный способ сообщить о провале долгой фоновой проверки.
Хук первый: звук, когда агент ждёт человека
Дальше три хука из моей практики. Начну с самого маленького.
Проблема бытовая. Агент работает несколько минут, вы уходите в другое окно, а он останавливается на вопросе или запросе разрешения. И ждёт, пока вы вспомните про терминал.
Решение — звук на трёх событиях: конец хода, обрыв хода из-за ошибки API, уведомление.
{ "hooks": { "Notification": [ { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] } ], "Stop": [ { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] } ], "StopFailure": [ { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] } ] }}Сам проигрыватель — маленькая программа на Go. Вся логика в выборе способа воспроизведения под текущую систему:
func main() { defer func() { _ = recover() os.Exit(0) // хук никогда не роняет сессию }()
sound := os.Args[1] if _, err := os.Stat(sound); err != nil { return }
switch runtime.GOOS { case "windows": // встроенный проигрыватель .NET, ничего доустанавливать не нужно play("powershell", "-NoProfile", "-Command", fmt.Sprintf("(New-Object Media.SoundPlayer '%s').PlaySync()", sound)) case "darwin": play("afplay", sound) default: // Linux: пробуем известные проигрыватели по очереди до первого сработавшего for _, c := range [][]string{ {"pw-play", sound}, {"paplay", sound}, {"aplay", "-q", sound}, {"play", "-q", sound}, {"canberra-gtk-play", "-f", sound}, } { if play(c[0], c[1:]...) { break } } }}Обратите внимание на первые строки. Хук обязан быть безобидным: нет звуковой карты, нет проигрывателя, нет файла — молча выходим с нулём. Падающий хук превращает мелкое удобство в постоянный источник ошибок в транскрипте.
Если уведомления кажутся слишком шумными, у события есть фильтры по типу: только запрос разрешения, только простой в ожидании ответа, только завершение фоновой сессии.
Хук второй: живые лимиты подписки
Самый полезный из трёх и самый показательный. Он делает то, чего модель про себя не знает в принципе.
У подписки есть пятичасовое окно, недельное окно и отдельные недельные корзины по моделям. Агент внутри сессии этих цифр не видит, поэтому не может соразмерять затраты. Он одинаково охотно запускает веер из десяти подагентов и когда до сброса лимита четыре часа, и когда бюджет уже на исходе. Заканчивается это тем, что лимит выбивается на середине задачи.
Хук закрывает четыре роли сразу.
Достаёт живые цифры. Клиент при входе сохраняет OAuth-токен локально. Хук читает его и спрашивает у API текущее потребление, никаких ключей API заводить не нужно:
func fetchUsage() json.RawMessage { b, _ := os.ReadFile(credPath) // токен, сохранённый при входе var cred struct { ClaudeAiOauth struct { AccessToken string `json:"accessToken"` } `json:"claudeAiOauth"` } if json.Unmarshal(b, &cred) != nil || cred.ClaudeAiOauth.AccessToken == "" { return nil } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/api/oauth/usage", nil) req.Header.Set("Authorization", "Bearer "+cred.ClaudeAiOauth.AccessToken) // ... ответ кэшируется на минуту, чтобы не ходить в сеть на каждый запрос}Данные общие на весь аккаунт: они учитывают и другие ваши сессии, что как раз и нужно.
Показывает их в строке состояния. Строка состояния настраивается там же, где хуки, и обновляется постоянно. Картинка всё время перед глазами:

Верхняя строка — заполненность контекстного окна: сколько токенов нёс последний запрос при каком размере окна. Ниже лимиты: пятичасовое окно, недельное и отдельная недельная корзина модели, которой сейчас идёт работа.
Здесь спрятан приём, который стоит забрать себе независимо от Claude Code. Каждый лимит показан двумя строками. Верхняя — сколько бюджета потрачено, нижняя — сколько времени окна уже прошло.
Сама по себе шкала «потрачено 35 %» не говорит ни о чём. Значение появляется, когда рядом стоит вторая. Бюджетная шкала обгоняет временную — до сброса вы не дотянете. Отстаёт — можно работать спокойно. Две бессмысленные по отдельности цифры вместе дают готовое решение.
Кладёт те же цифры в контекст модели. На старте сессии и на каждом запросе хук печатает ту же таблицу в стандартный вывод и выходит с нулём. Для этих событий этого достаточно, чтобы текст попал в контекст, отдельный JSON не нужен. К таблице добавляются две строки, которых в строке состояния нет: темп расхода с прогнозом, когда бюджет кончится, и текущая зона с указанием, какой из лимитов сейчас связывающий.
Запрещает веер агентов на исходе бюджета. Вот ради чего всё затевалось. Хук висит на событии перед вызовом инструмента с фильтром по инструментам запуска подагентов и рабочих процессов:
{ "PreToolUse": [ { "matcher": "Agent|Task|Workflow", "hooks": [ { "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/usage-limits\" --mode gate", "timeout": 20 } ] } ]}А сам гейт в горячей зоне отвечает решением:
if st.zone == "RED" || st.zone == "ORANGE" { decision := "ask" if st.zone == "RED" { decision = "deny" // веер подагентов сожжёт остаток бюджета } out := map[string]any{ "hookSpecificOutput": map[string]any{ "hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason, // сюда идут цифры и совет, что делать вместо этого }, } b, _ := json.Marshal(out) fmt.Print(string(b))}В красной зоне запуск подагента отменяется, в оранжевой выносится на подтверждение пользователю. Причина уходит модели текстом, и она перестраивает план: работает в одном потоке вместо веера.
Ещё одна деталь, которую не получить из самого события, — заполненность контекстного окна. Хук читает хвост файла транскрипта, находит последнюю запись с расходом токенов и складывает свежий ввод с чтением и записью кэша. Получается размер последнего запроса. Записи подагентов при этом пропускаются: у них свой контекст, их числа к основному диалогу отношения не имеют.
И вторая половина связки. Хук приносит факты, но факт сам по себе поведение не меняет. Рядом лежит правило, объясняющее, как эти цифры читать: сравнивать бюджетную шкалу с временной, соразмерять глубину проработки со сложностью задачи, считать веер из N агентов за N-кратный расход, при подходе к порогу сужать объём работы, а не тратить до потолка.
Каждая половина по отдельности бесполезна. Правило без хука опирается на выдумку: модель не знает своих лимитов и начинает их изобретать. Хук без правила выдаёт числа, с которыми никто ничего не делает. Вместе получается работающая экономия, и это, по-моему, главный рецепт из всей статьи.
Кстати, текст правила здесь — обычный промпт, и пишется он по тем же техникам: явная таблица пороговых значений вместо расплывчатого «экономь», описанный сценарий провала, чек-лист в конце. Разбор техник у меня в статье про промпт-инжиниринг, а про то, как устроены правила и скилы целиком, — в статье про практики работы с Claude Code.
Хук третий: автоматический журнал работы
Третий хук отвечает на вопрос «что вообще происходило в этой сессии».
Спросить об этом саму модель — плохая идея. Она перескажет свою работу по памяти, а память в конце длинной сессии уже сжата: часть деталей потеряна, часть пересказана оптимистично. Нужна механическая запись, сделанная не моделью.
Хук слушает почти все события сессии: старт и конец, каждый отправленный запрос, конец хода и его обрыв, запуск и остановку подагентов, сжатие контекста. Из инструментальных событий — только вопросы пользователю. Пишет один файл на сессию, с разбивкой по автору и дате.
Получается примерно так:
session start: source=startup · model=claude-opus-5
════════════════════════════════════════════════════════════
prompt:почини бесконечный редирект на протухшем токене, BUG-003
turn: model=claude-opus-5 · effort=high · 4m 12s · tools=23 (Read 9, Grep 5, Edit 3, Bash 6) · subagents=1
files: ~ apps/web/src/api/interceptors.ts +14/-6 +[41-48,52-57] -[41-46] ~ apps/web/tests/auth.spec.ts +31/-0 +[88-118]
answer:Причина была в том, что перехватчик повторял запрос со старым токеном...
session end: reason=prompt_input_exit · duration=51mТри приёма из этого хука стоят отдельного упоминания: их не выведешь из документации.
Подагентов нельзя считать по вызовам инструментов. Веерный запуск — это один вызов инструмента, порождающий десятки агентов. В транскрипте видна единица, в реальности тридцать. Поэтому счётчик набивается по событиям запуска подагента, а строка хода его читает и обнуляет:
// каждый SubagentStart дописывает один байт в счётчик сессии; запись только// в конец, поэтому одновременно стартующие агенты не затирают друг другаfunc noteSubagent(session8 string) { f, err := os.OpenFile(tallyPath(session8), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return } defer f.Close() _, _ = f.WriteString("x")}Сообщение, набранное во время хода, до хука не доходит. Оно не отправляется, а становится в очередь, поэтому событие отправки запроса по нему не срабатывает. В журнале появлялся бы ответ на вопрос, которого там нет. Такие сообщения вытаскиваются из транскрипта в конце хода и записываются отдельной пометкой перед тем ходом, который они прервали.
Уведомление о завершённой фоновой задаче приходит тем же событием, что и запрос пользователя. Записать его как «пользователь попросил» значит приписать человеку слова, которых он не говорил. Такие сообщения распознаются по началу текста и пишутся отдельной строкой.
Изменения файлов считаются через дифф гита относительно последнего коммита, с точностью до диапазонов строк. Гит при этом необязателен: нет его или каталог не репозиторий — статистика деградирует до простого списка путей, а остальное пишется как обычно.
Все хуки этого журнала висят в фоне. Они ничего не решают, только записывают, задерживать из-за них сессию незачем.
Почему компилируемый бинарник, а не скрипт
Все три хука — программы на Go, запускаемые тонким переходником на shell. Выбор неочевидный, объясню.
Скрипт на Node или Python выглядит проще ровно до момента, когда хук начинает висеть на каждом отправленном запросе. Старт интерпретатора — это десятки, а то и сотни миллисекунд на каждое событие. Плюс зависимость от того, что нужный рантайм установлен и нужной версии. Скомпилированный бинарник стартует мгновенно и не зависит ни от чего.
Второй довод — переносимость. В репозиторий кладутся собранные бинарники под три системы, а хук в настройках указывает на маленький переходник:
#!/bin/shcase "$(uname -s 2>/dev/null)" in Darwin*) name="usage-limits-darwin-arm64" ;; Linux*) name="usage-limits-linux-amd64" ;; MINGW*|MSYS*|CYGWIN*) name="usage-limits-windows-amd64.exe" ;; *) name="usage-limits-linux-amd64" ;;esac
# ... найти бинарник рядом с собой и передать ему аргументы и стандартный вводexec "$root/scripts/claude-code/$name" "$@"
# бинарника нет — выходим молча: хук не имеет права ломать сессиюexit 0Коллеге, который склонировал репозиторий, не нужен ни Go, ни Node. Только сам Claude Code. Компилятор нужен лишь тому, кто хук меняет.
Здесь же грабли, стоившие мне вечера. На Windows Claude Code запускает хуки через Git Bash, а Git Bash не переваривает первую строку файла с виндовым переводом строки: получается «плохой интерпретатор». Файл-переходник обязан храниться с юниксовыми переводами строк, и это надо закрепить в настройках гита:
.claude/hooks/* text eol=lfИначе автоматическое преобразование однажды всё сломает. Молча и на чужой машине.
Правила, которые экономят вечера
Практика, накопившаяся на трёх хуках и десятке отладок.
Хук не имеет права ронять сессию. Любая ошибка — молчаливый выход с нулём. Ненулевой код возврата от вспомогательного хука превращается в поток сообщений об ошибках при каждом ходе.
В стандартный вывод только то, что задумано. Если хук возвращает JSON, туда не должно попасть ни строчки лишнего. Классический случай: профиль оболочки печатает приветствие, оно приклеивается к JSON, разбор падает. Лечится проверкой на интерактивность в профиле:
if [[ $- == *i* ]]; then echo "Shell ready"fiОтладочные сообщения в поток ошибок. Он не мешает разбору и виден в отладочном логе.
Скорость важнее, чем кажется. Хук на отправке запроса стоит в критическом пути: каждые лишние полсекунды вы ждёте при каждом сообщении. Сетевые запросы кэшируйте.
Идемпотентность. Один и тот же хук может сработать дважды, параллельно, из двух сессий сразу. Дописывание в конец файла это переживает, перезапись нет.
Правки после хода стоит проверять целиком. Модель меняет файлы не только инструментами правки, но и командами оболочки, и фильтр по инструментам правки такие изменения не увидит. Нужна полнота — добавьте проверку рабочего дерева на завершении хода.
Осторожно с хуком на завершении хода. Он умеет заставить агента продолжить работу, и на этом легко построить бесконечный цикл. Защита есть, после восьми подряд блокировок хук перебивается, но лучше самому смотреть на поле, которое сообщает, что продолжение уже вызвано этим же хуком:
INPUT=$(cat)if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0 # уже крутимся по нашему же требованию — выходимfiТолько один хук должен подменять аргументы вызова. Хуки идут параллельно, при двух правках побеждает та, что закончилась последней. Порядок недетерминирован.
Безопасность
Скажу прямо, потому что хуки — готовый механизм выполнения произвольного кода.
Хук выполняется автоматически, с вашими правами, без всякого подтверждения. И получает на вход содержимое запросов и путь к транскрипту. Чужая конфигурация, найденная в интернете или пришедшая с pull request'ом, — это не «настройка редактора», а код, который начнёт исполняться у вас при следующем запуске.
Отсюда несколько практических следствий. Хуки проекта, лежащие в репозитории, стоит просматривать в ревью наравне с кодом. Секреты в командах хуков не пишутся: файл настроек попадает в репозиторий, для токенов есть переменные окружения. Для HTTP-хуков в организации предусмотрен список разрешённых адресов и список переменных, которые вообще разрешено подставлять в заголовки. Администратор может запретить пользовательские и проектные хуки целиком, оставив только корпоративные.
Со стороны самого Claude Code ограничения тоже есть: на macOS и Linux хуки запускаются без управляющего терминала, поэтому не могут писать прямо в интерфейс или подсовывать в него управляющие последовательности.
Отладка
Сначала три очевидные проверки, которые закрывают большинство случаев «не работает».
Команда /hooks показывает, зарегистрирован ли хук и из какого файла настроек он пришёл. Нет его там — дело в JSON или в расположении файла. Есть, но не срабатывает — почти наверняка не совпал фильтр.
Хук удобно тестировать вручную, без агента. Ему нужен только JSON в стандартном вводе:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.shecho $?Полная картина в отладочном логе: какие хуки сопоставились, с какими кодами вышли, что напечатали. Включается флагом при запуске или командой посреди сессии.
Типовые диагнозы по симптомам:
- «command not found» — относительный путь. Возьмите переменную с корнем проекта или перейдите на exec-форму.
- «jq: command not found» — утилита не установлена. Либо ставьте, либо разбирайте JSON тем, что уже есть.
- Хук не запускается вообще на macOS или Linux — забыт бит исполнения.
- Ошибка разбора JSON при заведомо корректном выводе — посторонний вывод из профиля оболочки.
Что вешать на хуки, а что нет
Хорошие кандидаты — всё, где нужна гарантия, а не намерение:
- форматирование и линт после правок;
- запрет на изменение чувствительных файлов и опасных команд, с объяснением, которое модель прочитает и учтёт;
- уведомления, когда агент ждёт человека;
- добавление фактов в контекст: ветка, окружение, лимиты, состояние стенда;
- журналирование и аудит, особенно там, где нужна прослеживаемость;
- восстановление контекста после сжатия;
- автоподтверждение узкого, заранее выбранного класса запросов разрешения.
Плохие кандидаты:
- сложная логика в хуке на каждом запросе — вы платите за неё задержкой при каждом сообщении;
- жёсткие запреты, которые обязаны быть непробиваемыми: для этого есть система разрешений, а фильтр хука по аргументам работает «на удачу»;
- всё, что удобнее сказать словами: поведение не критично и модель обычно и так делает правильно — правило дешевле;
- широкое автоподтверждение разрешений: фильтр вида «на всё» превращает подтверждения в фикцию, включая запись файлов и запуск команд.
И главное, ради чего всё это. Хуки — единственная часть Claude Code, которая не зависит от того, в каком настроении сегодня модель. Всё, что должно происходить всегда, должно быть хуком. Остальное можно оставить словам.
Про то, как организована работа вокруг агента целиком (инструкции проекта, правила, скилы, учёт задач, управление контекстом), у меня есть отдельная статья.