События hooks
Claude Code запускает команды hooks в определённые моменты задачи: отправлено сообщение, инструмент вот-вот запустится, инструмент закончил, задача завершилась и так далее. aipager цепляется за каждый из этих моментов и превращает их в действия в Telegram: рабочую карточку, запросы разрешения, ответы, уведомления и журнал аудита.
Как это подключено#
aipager config (или aipager setup) добавляет записи aipager в ~/.claude/settings.json, а aipager проверяет их при каждом запуске:
- одна команда
aipager-hookна каждое из 16 событий ниже, с фильтром*на событиях инструментов (PreToolUse,PostToolUse,PermissionRequest); - тайм-аут 5 секунд на
PreModelSwitchи, изaipager configиaipager setup, 30 секунд наPermissionRequest(сам hook ждёт не больше 20 секунд, см. ниже); остальные записи используют значение Claude Code по умолчанию; - команда
statusLine,aipager-statusline.
aipager uninstall снова убирает эти записи (см. команды).
Claude Code запускает новый процесс aipager-hook (aipager.dtach.notify_hook:main) на каждое событие. Он читает JSON события из stdin, добавляет два поля (ниже) и отправляет его aipager одной датаграммой через Unix-сокет. Сокет, по порядку: $AIPAGER_INSTANCE_DIR/aipager.sock для изолированного тестового экземпляра, $AIPAGER_SOCKET_PATH, $XDG_RUNTIME_DIR/aipager.sock или /tmp/aipager.sock. Отправка никогда не ждёт: если aipager не слушает, событие теряется, а монитор сессий догоняет по файлам сессии.
Большинство событий стоят Claude Code около 20-25 мс на запуск процесса. Некоторые ещё и загружают код правил aipager: перед вызовом инструмента (проверка безопасности, см. PreToolUse) около 70 мс, в конце задачи (чтобы снять отметку открытой задачи) около 55 мс, и UserPromptSubmit (который записывает правила задачи). PermissionRequest специально ждёт твоего нажатия, до 20 секунд.
Hook ограничивает свою память 1 ГБ. Hook, который упирается в лимит, завершается с ошибкой (для PreToolUse с кодом выхода, который Claude Code читает как отказ) и отправляет aipager датаграмму hook_memory_cap_hit, которая превращается в уведомление в чате с кнопкой «🐞 Сообщить об этом» для владельца установки (см. отчёты о проблемах). Задай AIPAGER_DEBUG=1 в окружении сессии, чтобы hook и строка состояния выводили свою диагностику в stderr.
Что получает aipager#
Датаграмма это собственный JSON hook от Claude Code с добавленными полями aipager (некоторые события несут больше, это указано у каждого события ниже):
| Поле | Значение |
|---|---|
hook_event_name | Событие (поле Claude Code; по нему aipager распределяет работу). |
session_id, transcript_path, cwd, ... | Собственные поля Claude Code, как пришли. session_id это ID переписки в Claude Code. |
session | Добавлено aipager: его имя для сессии, claude-<name>, из переменной CLAUDE_DTACH_SESSION, которую aipager задаёт, когда запускает сессию. События из сессии Claude Code, которую aipager не запускал, приходят без имени, и aipager их игнорирует. |
sl_tokens | Добавлено aipager, на каждом событии кроме MessageDisplay, когда существует файл строки состояния сессии: context_pct, total_output, total_input, current_output, lines_added и lines_removed. |
События инструментов несут ещё tool_name и tool_input. Каждое событие внутри субагента несёт agent_id (а события субагентов ещё и agent_type); у собственных событий основного цикла их нет, и aipager по этому признаку ставит вызовы инструментов агента на строку этого агента.
Справочник событий#
Обработчики aipager находятся в aipager/dtach/hook_receiver.py; собственная работа hook в aipager/dtach/notify_hook.py.
SessionStart#
Сессия запустилась, продолжилась или была сжата. Сессия, которую aipager ещё не отслеживает, регистрируется. После сжатия карточка показывает 📦 name · Переписка сжата: 82% → 4% (см. PreCompact). Для новой переписки hook ещё и снимает отметку открытой задачи с предыдущей задачи.
SessionEnd#
Сессия завершилась. Она отмечается как GONE: пропадает из закреплённого сообщения и переходит в завершённые сессии, откуда её может вернуть /resume. aipager session <name> или /new <name> запускает новую.
UserPromptSubmit#
Claude забрал сообщение, набранное в терминале или присланное из Telegram.
Сначала hook сверяет сообщение с заметками о разрешениях сессии (/tmp/claude-notes-<session>/, по одной на каждое сообщение Telegram, которое ввёл aipager) и записывает правила задачи в /tmp/claude-policy-<session>.json, которые читает проверка PreToolUse (см. безопасность → как правила действуют в команде). Он сообщает aipager, какие сообщения забрал Claude (датаграмма queue_pickup), прежде чем передать событие. Потом выводит дополнительный контекст для Claude, если он есть: стиль ответа, выбранный в /settings, и, при ответе на более старое сообщение, что в нём было и когда. Сообщение <task-notification> (Claude будит сам себя, когда фоновый агент или команда закончили) это продолжение той же работы: оно не совпадает ни с одной заметкой и сохраняет правила задачи.
aipager отмечает сессию как работающую, открывает рабочую карточку, если её нет (у сообщения из Telegram она уже есть), и меняет 👀 на сообщениях, которые забрал Claude, на 👍. Сообщение, отправленное во время идущей задачи, получает 👍, когда Claude включает его в эту задачу или начинает с ним следующую.
PreToolUse#
Инструмент вот-вот запустится. Здесь работает собственная проверка безопасности aipager, внутри hook, до того как Claude Code посмотрит свои правила разрешений:
- Задача, начатая из Telegram, подчиняется правилам роли того, кто её отправил, и нижнему порогу безопасности (защищённые пути, шаблоны команд), который обходит только роль
owner; задача, которую aipager не может ни к кому отнести, получает порог; сообщение, набранное тобой в терминале, не ограничено. Правила описаны в группах и безопасности. - Когда правило отклоняет вызов, hook выводит решение
deny, Claude Code отклоняет инструмент без вопросов, а aipager узнаёт об этом (safety_blocked): задача останавливается с🛑 x1 · Заблокировано правилом безопасности: ... (остановлена), и каждый следующий вызов инструмента в этой задаче тоже отклоняется. - Вызов
SendUserFile(Claude отправляет файлы в чат) проверяется для каждой задачи, включая задачи owner и сообщение, набранное в терминале: нижняя граница отправки, проверки файла и правила самой задачи (безопасность). Отклонённый вызов получаетdenyбез пометки «aipager safety policy»: Claude читает причину и сообщает тебе, задача продолжается, а hook отправляет aipagersend_files_refusedс каждым файлом, для журнала аудита. - Проверка, которая сама сломалась, отклоняет, если только правила сессии не удалось прочитать и они не дают исключение владельца.
На стороне aipager вызов становится строкой на рабочей карточке (вызов субагента попадает на строку этого агента), а Write или Edit показывает предпросмотр изменений, когда включено /settings → Предпросмотр изменений (по умолчанию выключено). Вызов AskUserQuestion превращается в вопрос с кнопкой на каждый вариант; вопрос, который задаёт вспомогательный агент Claude Code, когда никакая задача не идёт, не показывается (это не Claude спрашивает тебя). Собственный вызов Agent у Claude оставляет свои subagent_type и description для агента, которого он запускает, а вызов субагента это последнее действие этого агента: и то и другое видно в состоянии под ответом, пока агент работает в фоне.
PermissionRequest#
Claude Code собирается спросить разрешение. Рабочая карточка становится запросом, с настоящей командой или путём под описанием, которое даёт Claude (см. команды → запросы разрешения): Разрешить, Отклонить, Остановить, а Разрешать всегда только когда Claude Code предлагает настоящее правило, которое можно запомнить.
Hook отвечает на запрос сам, когда может. Он открывает короткоживущий сокет ответа, отправляет его адрес вместе с событием и ждёт твоего нажатия до 20 секунд. Вовремя сделанное нажатие возвращается как решение PermissionRequest (разрешить, разрешить с правилом или отклонить), так что Claude Code свой диалог не показывает. Если нажатие не пришло вовремя, hook сообщает aipager, что ожидание закончилось (permission_reply_timeout), и ничего не выводит; он ничего не выводит и тогда, когда до aipager не достучаться или на этом пути что-то сломалось. Тогда Claude Code показывает свой диалог в терминале, а более позднее нажатие отвечает на него, вводя клавиши. Журнал аудита (~/.claude/aipager-audit.jsonl) записывает, каким путём ответило каждое нажатие: "via": "hook_decision" или "via": "keystroke_fallback".
PermissionRequest, пришедший, пока открыт другой запрос, тоже показывается, своим сообщением под рабочей карточкой. Тот же диалог, пришедший снова (тот же спрашивающий, тот же инструмент и вызов или тот же ввод), показывается один раз. Claude Code запускает этот hook и для AskUserQuestion; этот вопрос уже показан по его PreToolUse, поэтому второй раз он не показывается никогда. Диалог фонового агента появляется в терминале только после того, как его hook перестал ждать, под всеми уже открытыми там диалогами; поэтому нажатие вводит клавиши только в тот запрос, который Claude Code показывает первым (см. команды → запросы разрешения).
PostToolUse / PostToolUseFailure#
Инструмент закончил или упал. Его строка на рабочей карточке застывает (ошибка отмечается на строке), а aipager замечает ответ на открытый запрос: старое Разрешить или Отклонить для него после этого ничего не вводит. Сессия хранит последние 200 строк инструментов.
Вызов Bash, который Claude Code перенёс в фон, сообщает backgroundTaskId. aipager тогда отслеживает его как фоновую команду (только команды основного цикла: вызов с agent_id принадлежит этому субагенту), пока не увидит её конец: <task-notification> со <status> (в сообщении, которое будит Claude, или в истории переписки, когда оно приходит посреди задачи), TaskStop или KillShell с её ID, завершение сессии или два часа (AIPAGER_BG_SHELL_MAX_TRACK, в секундах, по умолчанию 7200). Про её строку на карточке см. команды.
Вызов Agent (или Task) внутри субагента (его событие несёт agent_id) сообщает ID агента, которого он запустил. aipager запоминает, какой агент какого запустил, и так отличает агента, который остановился, чтобы подождать своего агента, от того, который закончил (см. SubagentStart / SubagentStop ниже).
После выполненного SendUserFile aipager сразу копирует его файлы, по разу на вызов и только во время задачи, начавшейся с промпта, по списку из его PreToolUse (никогда из результата инструмента), снова проверяя каждый; копии отправляются после ответа задачи, когда вызов найден в истории переписки, которую aipager запомнил для задачи, и записан во время этой задачи (безопасность, команды). Задача, которую Claude Code начинает без хука (сообщение, которое он берёт следующим, ответ Stop-хука), засчитывается, как только история переписки показывает её начало.
Notification#
aipager читает notification_type от Claude Code:
permission_prompt: более медленный и старый сигнал запроса разрешения. Он показывает запрос, только если ни один запрос не открыт: если какой-то открыт, это тот диалог, который уже показалPermissionRequest.idle_prompt, пока ждёт запрос разрешения или вопрос: сессия остаётся ожидающей, а чат получает⬆️ jim · всё ещё ждёт твоего ответа выше, один раз за ожидание.idle_promptв остальных случаях: обрабатывается какStop(ниже). Он подхватывает задачу, чейStopпотерялся; уже доставленный ответ никогда не отправляется снова.
Stop#
Задача закончилась. Сессия становится готовой, рабочая карточка застывает так, как говорит выбранный вид, и отправляется ответ Claude (из last_assistant_message события или иначе из истории переписки). Затем уходят сообщения, которые придерживались до конца задачи. Фоновые агенты и команды, которые ещё работают, держат работу открытой: карточка остаётся и показывает состояние работы, а ответ заканчивается живым состоянием этой работы: что делает каждый агент и сколько фоновых команд этой работы ещё идёт (см. команды → агенты, которые ещё работают). Сначала hook снимает отметку открытой задачи, если только у Claude нет сообщения в очереди, которое сразу становится следующей задачей.
StopFailure#
Задача закончилась ошибкой API. Сессия становится готовой, а ответ, если он есть, доставляется как при Stop; известная ошибка (перегрузка, лимит запросов, ...) показывается простыми словами.
SubagentStart / SubagentStop#
Субагент запустился или закончил. У каждого своя строка на рабочей карточке, 🤖 <type> · <activity> · <elapsed>, с его собственными вызовами инструментов, свёрнутыми под ней, и при остановке она застывает как ✅ 🤖 <type> · N инструментов · <elapsed>. SubagentStart не говорит, какой агент его запустил: aipager сопоставляет его с ожидающим вызовом Agent (PreToolUse) того же типа. Если все такие вызовы пришли от одного агента, новый агент сразу привязывается к этому агенту (его строка на карточке выглядит как 🤖 pipeline-runner › ship-tester, а в статусе под ответом он стоит под этим агентом); если от разных агентов, ничего не привязывается, пока собственный PostToolUse запустившего агента не назовёт его. Описание вызова используется, только когда ждёт ровно один вызов этого типа. Субагент, чей SubagentStop так и не пришёл, убирается, когда он молчит 30 минут (AIPAGER_SUBAGENT_SILENCE, в секундах; ни одного события любого вида с его ID). Работающий агент присылает события инструментов каждые несколько секунд, поэтому так убирается только агент, чью остановку пропустили (перезапуск, потерянная датаграмма); один вызов инструмента, который идёт 30 минут без единого события под ID этого агента, тоже его убирает. AIPAGER_SUBAGENT_TTL принимается как старое имя той же настройки. События SubagentStop без типа и с неизвестным ID приходят часто и игнорируются.
Фоновый агент, который останавливается, пока запущенный им агент ещё работает, тоже получает SubagentStop. aipager считает это паузой, а не окончанием: агент остаётся работающим (его строка, работа, состояние под ответами, закреплённое сообщение). Его следующий SubagentStart с тем же ID означает, что он продолжил, и не добавляет второй строки; любой его собственный вызов инструмента тоже считается продолжением. Он заканчивает, когда останавливается, а ни один из его собственных агентов уже не идёт. Если он не продолжил через две минуты после того, как закончился последний из них, он больше не считается работающим, но никогда не показывается законченным. Работающие агенты, в том числе ждущие, сохраняются при перезапуске aipager.
PreCompact / PostCompact#
Claude сжимает свой контекст (trigger равен auto или manual). Рабочая карточка показывает 🔄 name · Сжимает переписку (новое сообщение, если карточки нет), чтобы пауза не выглядела как падение. PostCompact заканчивает это состояние; строка 📦 name · Переписка сжата: X% → Y% приходит из следующего SessionStart или, если он пропущен, из строки состояния. Строка состояния ещё и даёт предупреждение ⚠️ name · Контекст заполнен на 82% - скоро сожмётся автоматически (с кнопкой Сжать), когда контекст доходит до 80 %, один раз за переписку: сжатие его не повторяет, а новая переписка (например, после /clear) получает его снова.
MessageDisplay#
Собственный текст Claude в момент, когда он появляется на экране. История переписки пишется только после каждого раунда вызовов инструментов, поэтому это единственный актуальный источник того, что говорит Claude, и именно он передаёт его комментарии в рабочую карточку. Текст субагента (любое событие с его agent_id) никогда не принимается за текст Claude: он не попадает ни в комментарии рабочей карточки, ни в Mini App, ни в журнал задачи и становится последним действием этого агента в состоянии под ответом. Hook не добавляет к этому событию данных строки состояния: Claude Code ждёт его, пока рисует, и оно приходит несколько раз за сообщение.
PreModelSwitch#
Claude Code 2.1.251 и новее вызывает его перед сменой модели. Содержимое несёт from_model, to_model, requested_model и source (command для набранного /model, либо picker или sdk). Перед сменой Claude Code обычно спрашивает «Switch model?», потому что следующий ответ должен перечитать всю переписку. Hook, который отвечает permissionDecision: "allow", пропускает смену без этого вопроса.
aipager-hook отвечает allow только на смену, которую ввёл сам aipager, из переключателя модели в Mini App или из клавиатуры моделей. Любая другая смена не получает ответа, поэтому Claude Code спрашивает, как обычно. Это относится к /model, набранному в терминале, к выбору в списке и к смене через SDK.
aipager узнаёт свои смены по файлу-отметке. Прямо перед тем, как ввести /model <name>, aipager пишет aipager-modelswitch-<session>.json:
- он лежит в папке, где находится управляющий сокет (обычно
$XDG_RUNTIME_DIR); - он пишется атомарно, с правами 0600;
- в нём указаны модель и ID сессии Claude;
- он истекает через 30 секунд.
Hook отвечает allow, только если выполнено всё это:
- отметка это файл, который принадлежит тебе, и не символическая ссылка;
- она не истекла;
- она для той же сессии aipager и того же ID сессии Claude;
- её модель равна
requested_modelилиto_modelбез учёта регистра; sourceравенcommand.
Затем hook забирает отметку атомарным переименованием, чтобы она сработала ровно один раз. Проверка читает один локальный файл, без сокета и без ожидания. Событие не передаётся aipager.
Claude Code 2.1.101 и новее игнорирует незнакомые события hooks, поэтому более старые версии без PreModelSwitch это не затрагивает. Один случай всё ещё может показать вопрос: смена на opusplan или с него может заставить Claude Code спросить hook дважды, и только первый вопрос находит отметку, так что Claude Code спрашивает «Switch model?» в терминале.
Строка состояния#
Это не событие hook: Claude Code запускает команду statusLine, aipager-statusline (aipager.dtach.statusline_notify:main), каждый раз, когда перерисовывает свою строку состояния. Команда:
- записывает JSON состояния Claude Code в
/tmp/claude-status-<session>.json(его читают hook дляsl_tokens,aipager statusи aipager); - отправляет aipager датаграмму
statuslineс моделью, % контекста, стоимостью, числом токенов и добавленными и удалёнными строками, которые идут в рабочую карточку,/status, Mini App, проверку того, что смена модели прошла, и предупреждение о контексте; - выводит строку состояния терминала:
[name] Opus 5.5 | 42% ctx | $1.23.
Собственные датаграммы aipager#
Кроме событий hooks, вспомогательные программы отправляют aipager несколько своих сообщений через тот же сокет:
| Сообщение | Кто отправляет | Когда |
|---|---|---|
queue_pickup | hook | При UserPromptSubmit: какие сообщения Telegram забрал Claude. |
safety_blocked | hook | При PreToolUse, когда проверка безопасности отклонила вызов. |
send_files_refused | хук | При PreToolUse, когда файлы, которые Claude хотел отправить, отклонены: каждый файл и причина, для журнала аудита. |
permission_reply_timeout | hook | Ожидание PermissionRequest закончилось без нажатия. |
statusline | строка состояния | При каждой перерисовке строки состояния. |
hook_memory_cap_hit | hook, строка состояния | Процесс упёрся в свой лимит памяти. |
report_error | hook, команды aipager | Случилась ошибка: только тип ошибки и её место в коде aipager, никогда не содержимое события, для отчётов о проблемах. |
Путь запроса разрешения#
claude aipager-hook aipager daemon Telegram
| | | |
| PermissionRequest | | |
|--------------------->| opens a reply socket | |
| |------------------------>| busy card becomes |
| | | the prompt |
| | |-------------------->|
| | | you tap Allow |
| | |<--------------------|
| | allow (reply socket) | audit log |
| |<------------------------| |
| decision: allow | | |
|<---------------------| | |
| tool runs | | |
Если нажатия нет 20 секунд, hook ничего не выводит, и Claude Code показывает свой диалог; более позднее нажатие вводит ответ в этот диалог через терминал сессии (dtach -p). В обоих случаях нажатие записывается в журнал аудита и публикуется под карточкой (✅ jim · Разрешено · @alice · Bash: ls -la /tmp).
См. также#
- Архитектура: где находится приёмник hooks.
- Команды бота → запросы разрешения: сторона, которую видишь ты.
- Модель безопасности: что проверка безопасности покрывает и что нет.