# Перехват вывода 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 символов с маркером обрезки внутри. Восстановить полный вывод после факта нельзя.

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

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

**rtk**[^1] ставит хук `PreToolUse`, который переписывает команды по списку: `git status` превращается в `rtk git status`, `cat` — в `rtk read`. Заявлена экономия 60–90 %. Бенчмарк JetBrains[^2] на 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. Механизм назван прямо — сжатый вывод лишает модель данных, и она добирает их лишними ходами.

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

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

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

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

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

```bash
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` до этого окружения доходят. Значит в каждую команду можно вставить преамбулу, ничего не переписывая в самой команде.

```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: модель не увидит нужного и пойдёт добирать лишними ходами. Разница в том, что фильтр ничего не понимает в содержимом и ниже порога не трогает вывод вообще, но проверять это всё равно надо счётом, а не рассуждением.

[^1]: [RTK — Rust Token Killer](https://www.rtk-ai.app/)

[^2]: [rtk Claude Code Token Savings: A Skill Trial Benchmark](https://blog.jetbrains.com/ai/2026/07/rtk-claude-code-token-savings/)

[^3]: [RTK Hook Increases Claude Code Costs by 18%](https://github.com/rtk-ai/rtk/issues/582)

[^4]: [nopeek — Keep Your Secrets Out of Claude Code](https://scottspence.com/posts/nopeek-keep-secrets-out-of-claude-code)

[^5]: [Claude Code: settings](https://code.claude.com/docs/en/settings)

