Перейти к основному содержанию

Перехват вывода Bash в Claude Code через BASH_ENV

Claude Code ограничивает вывод команд, чтобы он не съел контекст. Успешный результат приходит целиком примерно до 30 000 символов, а всё, что больше, клиент сохраняет в файл сессии и отдаёт превью вместе с путём к нему. С провалом иначе: он приходит обрезком примерно до 10 000 символов и без пути к файлу. Полного вывода упавшей команды взять неоткуда.

Из-за этого агент вынужден заранее угадывать, какая команда упадёт, и писать go test ./... > /tmp/log 2>&1 вместо go test ./.... Угадывает он плохо: команда, вывод которой понадобится целиком, обычно та, которая неожиданно сломалась.

Разбирался с этим Opus в Claude Code: замеры, код и тесты его. Моё — постановка задачи, правки рамки и решения. Дальше я развожу это по именам, чтобы было видно, кто что сделал. Цифры сняты на моей машине 5 августа 2026 года, Claude Code 2.1.222, macOS.

Что клиент делает с выводом

Первое, что стоило проверить, — можно ли просто поднять лимит. В Claude Code есть переменная BASH_MAX_OUTPUT_LENGTH, по умолчанию 30 000.

Прогон: команда печатает 4000 строк в stderr, последней строкой — маркер, затем exit 11. С поднятым до 150 000 лимитом в результат пришло больше текста, но видимый конец — строка MAXOUT_02777, а маркер последней строки не пришёл.

Клиент удерживает голову потока. Лимит двигает границу того, сколько сохранено с начала; всё, что за ней, отбрасывается совсем. Никакая настройка конец вывода не вернёт.

Вторая надежда — хуки. У Claude Code есть событие PostToolUseFailure, и в нём действительно лежит команда, рабочий каталог, длительность и текст ошибки. Но текст уже обрезан ровно так же, как то, что видит модель: на маленьком провале 33 символа, на провале со 172 КБ в stderr — 10 040 символов с маркером обрезки внутри. Восстановить полный вывод после факта нельзя.

Остаётся одно: не пускать объём в клиент в исходном виде.

Готовые решения

rtk1 ставит хук PreToolUse, который переписывает команды по списку: git status превращается в rtk git status, cat — в rtk read. Заявлена экономия 60–90 %. Бенчмарк JetBrains2 на 86 задачах SkillsBench с Claude Sonnet 5 и 425 оплаченными прогонами показал другое: на низком уровне рассуждений стоимость выросла на 7,6 % (p=0,004) при +13,8 % ходов, а класс «новый ввод», где сжатие и должно было сработать, сдвинулся на статистически неотличимые от нуля 3,2 %. На высоком уровне рассуждений разница исчезает: +0,1 % (p=0,99). В трекере самого rtk висит открытая задача3 с похожим замером: выходные токены 1,6k → 2,4k, стоимость $0,68 → $0,80. Механизм назван прямо — сжатый вывод лишает модель данных, и она добирает их лишними ходами.

nopeek4 решает соседнюю задачу: тот же PreToolUse заворачивает команды облачных CLI через sed и вырезает секреты до того, как вывод попадёт в контекст. Интересно там ограничение, которое автор поставил себе сам: команды с пайпами и перенаправлениями хук не трогает, потому что менять семантику сложной команды слишком рискованно.

Дедупликация повторного вывода — идея ловить повторный запуск той же команды и не отдавать модели один и тот же текст дважды. На моём профиле за 30 дней потолок такой экономии оказался 2,7 % вывода Bash. Общего смысла в подходе это не отменяет, но платить за него отдельной сущностью не за что.

Общее у первых двух: оба переписывают строку команды, оба ведут список того, что переписывать, и оба стоят на PreToolUse. Другой точки перехвата в найденных решениях нет.

Своя обёртка и почему её сняли

До этого у меня был скилл bounded-command — детерминированная обёртка над argv:

python3 scripts/bounded_command.py -- go test ./...

Обёртка запускала команду, отдавала модели 20 строк при успехе и 120 при провале, полный лог отправляла в файл и печатала путь. Для компиляторов был флаг --head-lines, потому что корневая ошибка сборки идёт первой, а производные — после неё, и хвост там не тот конец.

Работала она нормально. Сняли её по другой причине. Замер по 399 сессиям показал, что клиент и так ограничивает вывод сам, а обёртке остаётся полоса между краями: успешные команды с выводом от 8 000 до 30 000 символов. Это 114 результатов за 30 дней, 8,2 % вывода Bash и около 3,5 % всего, что возвращают инструменты. Слишком мало, чтобы держать ради этого скилл, который агент должен помнить и вызывать руками.

Вместе с обёрткой ушёл и единственный плюс, что она реально давала, — полный лог упавшей команды.

Брейншторм на двух моделях

Дальше я запустил обсуждение Opus c DeepSeek по схеме со слепым первым раундом: обе стороны пишут списки вариантов независимо, не видя чужого, и только потом списки критикуются и объединяются.

Из пяти механизмов, которые предложил DeepSeek, три совпали со списком Opus. Ценными оказались расхождения.

Довод, которого у Opus не было: направление лишнего процесса. Если фильтр — потомок команды и просто потребляет данные, убийство команды его не ломает. Если фильтр — предок, то есть команда запускается через него, то сигнал прилетает в фильтр, а команда остаётся сиротой: пользователю сказали «прервано», а git push доезжает. Этот довод перевесил главный аргумент Opus за вариант с обёрткой.

Второе: Opus собирался ловить ошибки в выводе по шаблонам вроде --- FAIL и panic:. DeepSeek предложил вместо этого опираться на код возврата и проверить, совпадают ли множества. Проверка на 30 днях транскриптов: среди результатов длиннее 4000 символов провалов было 22, а шаблоны срабатывали на 75 успешных выводах. В примерах — grep -niE 'error|panic|fatal' и листинги исходников. Список шаблонов ловил бы в основном сам себя.

Решение: преамбула через BASH_ENV

Bash при неинтерактивном запуске читает переменную BASH_ENV и исполняет указанный в ней файл перед командой. Claude Code запускает команды инструмента как bash -c '<команда>', а переменные из блока env в settings.json до этого окружения доходят. Значит в каждую команду можно вставить преамбулу, ничего не переписывая в самой команде.

{
  "env": {
    "BASH_ENV": "/Users/me/.claude/scripts/bash-preamble.sh"
  }
}

Приём нигде не описан. В документации env объясняется как «переменные окружения, применяемые к каждой сессии и к подпроцессам, которые Claude Code из неё порождает»5; ни BASH_ENV, ни стартовые файлы bash там не упоминаются, и в примерах речь только про NODE_ENV, адреса сервисов и прочие значения, которые ждут скрипты проекта. Поиск по сообществу тоже ничего не дал: про перехват вывода пишут через PreToolUse, про BASH_ENV — никто.

Преамбула поднимает две именованные трубы, запускает фильтр фоновой задачей и заворачивает в него stdout и stderr. Фильтр пропускает первые 4000 символов каждого потока как есть, остальное пишет в файл и по завершении отдаёт свёрнутый объём, настоящий хвост и строку с кодом возврата и путём.

Хука здесь нет намеренно. Хук стоял бы в критическом пути каждого вызова Bash и при собственном падении блокировал бы их все. Если у преамбулы что-то не получилось (нет python3, не создалась труба, нет файла фильтра), она просто выходит, и команда идёт как есть.

Отдельно: путь к логу печатается в момент пересечения порога, а не в конце. Иначе его не увидит ни фоновая задача, вывод которой читают по ходу, ни команда, которую убили до завершения.

Два места, где Opus ошибся

Оба раза его тест был мягче реальности и оба раза показывал зелёный.

Первое. Opus проверял, доедет ли строка с путём, если команда перезапишет trap EXIT своим. Тест шёл через подстановку команд $(…), а она читает трубу до конца потока. Claude Code так не делает: он закрывает результат по выходу прямого потомка. Проверка на живой команде: ( sleep 3; echo MARKER ) & echo EARLY вернулось мгновенно и без маркера.

Второе. DeepSeek предложил обойтись одной трубой, подмешивая stderr туда же с префиксом. Тест Opus это подтвердил, так и собрали — и на первом же боевом прогоне в логе оказалось:

LIVE_03157 vvvvvvvvvvvvvvvvvvvvvvvv[stderr] LIVE_REAL_ERROR: something broke

Строка stderr влипла в середину строки stdout. Запись больше PIPE_BUF не атомарна, ядро её расщепляет, и атомарная запись второго писателя ложится в шов. Явный перевод строки не спасает: рвалась строка stdout, а не stderr. Тест этого не поймал, потому что писал по одной короткой строке, а такие записи атомарны.

Отсюда правило, которое Opus записал себе в память отдельно от кода: прежде чем считать прогон подтверждением, назвать, чем тестовая обвязка мягче боевой среды. Если мягче хоть в одном — зелёный результат подтверждает обвязку, а не систему.

Тесты

Сценариев 33, обычный shell-скрипт. Проверяются: сквозной проход малого вывода байт в байт и отсутствие файла ниже порога; сохранение кода возврата на малом и большом выводе; голова, хвост и путь при свёртке; полнота лога; появление stderr в выводе при провале; отсутствие склейки строк на выводе больше PIPE_BUF; отсутствие двойной обёртки во вложенном bash -c; невмешательство в скриптовую форму вызова, которой запускаются хуки; работа команды при отсутствующем фильтре.

Регрессия на склейку добавлена после того, как дефект нашёлся вживую, — иначе он вернулся бы при следующей правке.

Результат

Фоновая команда со 137 КБ вывода: файл задачи замер на 4315 байтах, то есть на голове и строке с путём, а растущий лог читался напрямую по ходу выполнения. По завершении в результат доехали свёртка, настоящий хвост и трейлер, итого 5989 байт. В логе все 2000 строк stdout и 10 строк stderr с меткой, склеек нет.

Прерывание и таймаут проверены отдельно, потому что довод про направление процесса бил и по преамбуле: без неё bash -c 'sleep 20' подменяет себя командой через exec, а с ней остаётся оболочка-обёртка. В симуляции kill -TERM по оболочке действительно оставлял команду живой. На реальных путях не воспроизводится: по Esc команда встала и файл дальше не рос, по таймауту пришёл exit 143, сирот с родителем init нет ни там, ни там. Claude Code бьёт по дереву процессов.

Радиус действия ограничен сам собой: BASH_ENV читает только bash, и преамбула оборачивает только форму bash -c. Хуки и строка статуса запускаются скриптом и пропускаются. MCP-серверы недостижимы — все они стартуют напрямую, bash в цепочке нет.

Чего пока не знаю

Порог в 4000 символов взят из распределения размеров вывода, а не из смысла. По моим транскриптам за 30 дней 48,5 % вызовов Bash дают меньше 500 символов и вместе несут 7 % объёма, а 8,9 % вызовов с выводом больше 4000 символов несут 46 %. Но частота размеров не отвечает на вопрос, с какого объёма текст перестаёт быть полезным.

Правильная проверка порога — считать, как часто приходится лезть в полный лог. Если часто, порог занижен и свёртка ест нужное. Такие данные появятся только на длинном пробеге в обычной работе, поэтому порог вынесен в переменную, а не зашит в код.

Второе, за чем стоит следить, — та самая свёрнутая середина. Это единственное место, где фильтр может укусить так же, как rtk: модель не увидит нужного и пойдёт добирать лишними ходами. Разница в том, что фильтр ничего не понимает в содержимом и ниже порога не трогает вывод вообще, но проверять это всё равно надо счётом, а не рассуждением.