Однажды я посчитал, сколько экземпляров моего агента живёт на моей же машине. Четыре демона, от 160 до 250 мегабайт каждый, и три из них недостижимы: ни терминал, ни браузер, ни команда статуса о них не знали. Примерно 515 мегабайт памяти, занятых процессами, к которым невозможно подключиться. Они не падали и не мешали. Они просто были.
Это цена решения, которое здесь разбирается: агент перестал быть утилитой командной строки и стал сервером. Зачем это понадобилось, что это дало и во что превратилась простая, казалось бы, задача «запустить процесс и потом его остановить».
Два писателя
Долгое время у агента было два лица. Терминальный интерфейс запускал цикл прямо у себя в процессе. Веб-интерфейс жил отдельным демоном и реализовывал тот же цикл заново, своим кодом. Обе стороны писали в одну базу сессий.
Ломалось это предсказуемо. Два варианта одного цикла приходилось держать в согласии руками. Два процесса писали в одну SQLite, и сессия, открытая одновременно в терминале и в браузере, делила между ними только периодический снимок из базы: ни живого потока токенов, ни общей кнопки «остановить». Нажал abort в браузере, терминал продолжает.
Решение я принял в версии 0.12.29 и с тех пор не пожалел: единственный писатель. Демон владеет циклом для всех сессий и один пишет в базу. Терминальный интерфейс и браузер стали тонкими клиентами: отправляют сообщения по HTTP, получают события по SSE, потоку событий сервера, стандартной части HTML, которую браузеры умеют больше десяти лет. Когда запускаешь cast без аргументов, он ищет живой демон в ~/.cast/server.json. Не находит, поднимает его сам на loopback, ждёт, пока тот действительно займёт порт, и подключается. Стоимость видна сразу: демон появляется, даже если ты хотел только терминал. Это осознанная плата за то, что описано дальше.
Что даёт сервер
Первое: одна сессия видна отовсюду. Открываешь её в терминале и в браузере на телефоне, и токены, вызовы инструментов, статус идут в обоих одновременно, потому что оба читают один и тот же поток событий из одного процесса. Остановка из любого клиента останавливает ход для всех.
Второе: агент переживает клиента. Закрыл терминал, демон продолжает. Фоновая команда bash, начатая в прошлом ходу, субагент, писарь чекпоинтов. Всё это работа, которой нет дела до того, смотрит ли кто-то на экран. Утром открываешь браузер и видишь, что случилось ночью.
Третье: клиентов стало много, и они дёшевы. Неинтерактивный cast run для скриптов и интеграций это клиент. Постоянная JSONL-сессия для программных клиентов это клиент. И редактор тоже клиент: агент умеет говорить по ACP, протоколу между редакторами и агентами, который Zed опубликовал в августе 2025 года, а осенью к нему присоединились JetBrains. Это JSON-RPC по stdio, редактор запускает агента как подпроцесс. Аналогия с LSP прямая, и смысл тот же: агент, реализовавший протокол, работает в любом совместимом редакторе. Для меня это означало написать один транспорт поверх того же движка. Ещё раз писать цикл не понадобилось.
Устроено это через события. Каждое изменение состояния цикла, токен, начало и конец вызова инструмента, расход, конец хода, компакция, это событие с типом. Демон превращает его в событие для клиентов и рассылает всем подписанным. Клиенты рисуют то, что получили, и никогда не выводят состояние цикла заново сами. Одно место истины, много проекций.
Жизнь процесса
Здесь начинается то, что я недооценил.
Файл ~/.cast/server.json это единственный способ найти демон. В нём PID, порт, адрес, версия протокола, случайный идентификатор экземпляра и локальный токен. Первое правило: файл пишет сам сервер, и только после того, как реально начал слушать порт. Если бы его писал запускающий процесс сразу после spawn, «демон уже запущен» означало бы «какой-то дочерний процесс стартовал», и этому нельзя было бы верить.
Второе правило: каждый, кто читает файл, проверяет, жив ли процесс с записанным PID, и если нет, считает запись устаревшей и удаляет её. Это единственный способ пережить kill -9, падение по памяти или закрытое окно терминала. SIGKILL нельзя перехватить, поэтому восстановление происходит при следующем чтении файла, когда умирающего процесса уже нет.
Третье: версия протокола. Клиент сравнивает записанную версию со своей. При несовпадении демон не трогается. Пользователю говорят остановить его явно. Молча убивать чужой процесс, который, возможно, чем-то занят, я не рискую.
Сессии без подключённого клиента выгружаются из памяти демона через пять минут и лениво поднимаются из базы, когда кто-то откроет их снова.
А теперь те четыре демона. Экземпляр, который уже занял порт, при потере записи в server.json продолжает обслуживать вместо того, чтобы умереть посреди запроса. Это правильно. Но запись это единственный способ его найти, и незарегистрированный демон становится невидимым навсегда: ничто его не завершало. Теперь такой экземпляр каждые тридцать секунд проверяет, значится ли он в файле, и если нет, завершается. С двумя предохранителями. Первый это пауза после старта, потому что запись появляется после привязки к порту, и другой процесс может быть посреди записи. Второй: только когда демон полностью пуст.
«Полностью пуст» это формулировка, которую пришлось исправлять. Первая версия спрашивала «есть ли сессия с ходом в работе». Для демона, весь смысл которого в том, чтобы переживать клиентов, это неправильный вопрос. Фоновая команда, запущенная уже завершившимся ходом, субагент, писарь чекпоинтов, открытая вкладка браузера. Ничто из этого не делает сессию «работающей», и всё это нельзя обрывать. Сейчас условие звучит так: ни одного хода, ни одного слушателя, ни одной фоновой задачи, ни одного актора в ожидании или в работе. То же самое условие, по которому выгружаются простаивающие сессии. Демон, запущенный в переднем плане, не завершается никогда: за ним смотрит человек.
И обновление. Кнопка «обновить» в браузере запускает установщик внутри демона, после чего перезапускает демон по записанному PID. Записанный PID это он сам. Он получал сигнал, закрывал сессии и выходил за секунды, до того, как успевал запустить замену. Новая версия установлена, демона нет, веб-интерфейс мёртв. Теперь в этом случае порождается отдельный ждущий процесс, который стартует новый демон после того, как старый исчез, и только затем старый посылает сигнал сам себе, чтобы сессии успели завершиться.
Кому можно
Сервер это дверь, и у неё две стороны.
Локальная. Демон на loopback пишет в server.json токен. Терминальный клиент на той же машине под тем же пользователем читает его оттуда и подключается без логина. Токен никогда не передаётся демоном по сети и принимается только с loopback-сокета: даже если демон привязан к 0.0.0.0, терминал ходит через 127.0.0.1, а внешний браузер токеном воспользоваться не сможет.
Внешняя. При первом запуске веб-интерфейса генерируется пароль, логин всегда cast, сессия живёт в HttpOnly-cookie с SameSite, ответы API не кешируются. Флаг --public отдаёт обычный HTTP: он годится только для доверенной локальной сети и не защищает ни пароль, ни сессию от наблюдателя в ней. Для доступа снаружи без домена и HTTPS я оставляю loopback и пробрасываю порт через ssh -L.
И отдельный класс это публичная ссылка на сессию. Она намеренно открыта: токен в адресе это единственный ключ. Сохранённый вид аккуратен, сообщения инструментов из него вырезаны целиком, чтобы открывший ссылку не увидел, что печатала команда и что лежало в файле. Живая трансляция той же сессии аккуратной не была. События начала и конца вызова инструмента уходили целиком, с аргументами и результатом, и анонимный зритель, смотревший поток во время работы, видел ровно то, что статический вид скрывал: содержимое файлов, вывод команд, пути. Теперь транслируются имя инструмента и статус. Этого достаточно, чтобы за трансляцией было интересно следить, и ничего сверх.
Что из этого следует
Ни одна из проблем этой части не про языковые модели. Сироты, лизы, версии протокола, гонки двух писателей, процесс, убивающий себя до перезапуска, утечка через трансляцию это распределённая система в миниатюре, и она возникает ровно в тот момент, когда агент должен работать дольше, чем открыт терминал.
Именно это и было целью. Агент, который живёт только пока на него смотрят, это инструмент. Агент, который продолжает после того, как ты закрыл ноутбук, и которого можно проверить с телефона, это уже сотрудник. Разница между ними: один процесс, единственный писатель и поток событий, на который подписываются все остальные.