Claude Code и Codex умеют работать долго и без присмотра, но только пока ты сидишь рядом. Агент то спрашивает разрешение на команду, то задаёт уточняющий вопрос и ждёт ответа. Отошёл от компьютера — и всё встало.
Как-то мне пришлось срочно уехать по делам, а задач на день было много. Хотелось ставить их агентам с телефона и подтверждать команды кнопкой, а не держать открытыми шесть терминалов. Так появился agent-hub: десктопное приложение на Rust, в котором каждая тема Telegram-группы — отдельная сессия агента на моём компьютере. За 3-4 часа в дороге я провёл через него больше шести задач.
Похожие решения есть, но мне не подошли. Мне важно, чтобы у каждой задачи была своя история переписки и не приходилось помнить ID сессий, а темы в группе для этого подходят идеально.
В статье расскажу не столько про сам инструмент, сколько про то, как он устроен внутри: как четыре агента с разными протоколами сведены к одному интерфейсу, как запрос разрешения превращается в кнопку Telegram и обратно и какие ограничения Telegram пришлось обходить.
Что должен уметь хаб
Если посмотреть на работу с агентом со стороны пользователя, взаимодействий немного:
— агент пишет текст;
— агент вызывает инструмент (команду, правку файла, поиск);
— агент просит разрешение на вызов;
— агент задаёт вопрос с вариантами ответа;
— агент присылает файл;
— ход закончился или упал с ошибкой.
В обратную сторону идут сообщения пользователя, ответы на разрешения и вопросы, файлы и команда остановки. Всё это нужно уметь показать в теме Telegram и вернуть агенту. Проблема в том, что каждый агент говорит об этом на своём языке.
Четыре протокола
Все четыре агента запускаются как дочерние процессы и общаются через stdin и stdout, но на этом сходство заканчивается.
|
Агент |
Запуск |
Протокол |
|
Claude Code |
|
JSON-строки плюс управляющие запросы |
|
Codex |
|
JSON-RPC: треды, ходы, элементы |
|
Qwen Code |
|
Agent Client Protocol |
|
Hermes |
|
тоже ACP |
Лучше всего разницу видно на одном и том же событии — запросе разрешения.
Claude Code присылает управляющий запрос и ждёт ответа с тем же request_id:
{"type": "control_request", "request_id": "c76cce43-…", "request": {"subtype": "can_use_tool", "tool_name": "Write", "input": {"file_path": "/work/note.txt", "content": "hi"}}}
Ответ хаба:
"type": "control_response", "response": {"subtype": "success", "request_id": "c76cce43-…", "response": {"behavior": "allow", "updatedInput": {"file_path": "/work/note.txt", "content": "hi"}}}}
При отказе вместо allow уходит {"behavior": "deny", "message": "…"}, и текст причины агент видит как результат инструмента.
Codex — это JSON-RPC, и для команд и правок файлов у него разные методы: item/commandExecution/requestApproval и item/fileChange/requestApproval. Из параметров хаб берёт command или reason, а отвечает коротко:
{"decision": "accept"}
или
{"decision": "decline"}
Qwen работает по ACP. Запрос session/request_permission приходит со списком вариантов, и клиент должен выбрать один из них:
{"sessionId": "s-1", "toolCall": {"title": "rm -rf build", "kind": "execute", "rawInput": {"command": "rm -rf build"}}, "options": [ {"optionId": "proceed_always_project", "kind": "allow_always"}, {"optionId": "proceed_once", "kind": "allow_once"}, {"optionId": "cancel", "kind": "reject_once"}]}
{"outcome": {"outcome": "selected", "optionId": "proceed_once"}}
Здесь я сознательно никогда не выбираю allow_always: с телефона легко нажать не туда, поэтому каждый вызов снова идёт к человеку. У Qwen есть и неочевидная особенность: свои уточняющие вопросы он присылает тем же session/request_permission, только с пометкой в _meta. Хабу приходится смотреть внутрь, чтобы понять, разрешение это или вопрос.
Общий слой
Чтобы Telegram не знал про протоколы, а агенты не знали про Telegram, между ними стоит небольшой трейт. Это «человек на той стороне»:
/// Boxed futures keep the trait object-safe, so the backend does not depend on the messenger.pub trait UserChannel: Send + Sync { fn request(&self, tool: ToolRequest) -> BoxFuture<'_, Decision>; fn ask(&self, questions: Vec<Question>) -> BoxFuture<'_, QuestionsOutcome>; fn send_file(&self, file: OutgoingFile) -> BoxFuture<'_, FileDelivery>;}
Каждый агент переводит свой запрос в ToolRequest, спрашивает канал и переводит Decision обратно в формат своего протокола. Для Codex это выглядит так:
match method { "item/commandExecution/requestApproval" => { Ok(approve(channel, request(SHELL, &command_summary(¶ms))).await) } "item/fileChange/requestApproval" => { Ok(approve(channel, request(PATCH, &file_change_summary(¶ms))).await) } "item/tool/call" => Ok(tool_response(call_tool(channel, ¶ms).await?)), "item/tool/requestUserInput" => user_input(channel, ¶ms).await, other => Err(RequestError::Unsupported(other.to_owned())),}
Всё остальное, что агент сообщает сам, без ответа, сводится к одному перечислению:
pub enum AgentEvent { SessionStarted(SessionId), AssistantText(String), ToolCall(ToolUse), Finished(Finished), Failed(String), BackgroundAbandoned(Vec<String>), Quota(QuotaUpdate),}
Код разбит на крейты: hub-core без ввода-вывода (команды, пути, отрисовка), hub-agent с общими типами и JSON-RPC, по крейту на каждого агента, hub-telegram на teloxide и hub-app с окном на egui. Telegram-крейт ничего не знает про control_request или ACP, а крейт агента — про кнопки. Чтобы добавить нового агента, нужен только новый крейт, который реализует перевод.
Как запрос превращается в кнопку и обратно
Агент ждёт ответа внутри своего протокола, а пользователь нажмёт кнопку когда-нибудь потом, в другом обработчике. Связать их помогает реестр ожидающих запросов и oneshot-канал:
async fn approval(&self, tool: ToolRequest) -> Decision { let id = ApprovalId::from_random(random_id()); let (responder, decision) = oneshot::channel(); lock(&self.registries).approvals.open(id.clone(), responder); // ... отправляем сообщение с кнопками match tokio::time::timeout(self.timeout, decision).await { Ok(Ok(decision)) => decision, Ok(Err(_)) => Decision::Denied(/* канал закрыт /), Err(_) => { // правим сообщение: «⌛ Нет ответа - запрещено» Decision::Denied(/ тайм-аут */) } }}
У кнопок Telegram в callback_data помещается не больше 64 байт, поэтому туда кладётся только короткая строка вида ap:allow:<id> со случайным идентификатором. Обработчик нажатия разбирает её, находит в реестре нужный oneshot и отправляет в него решение. Агент в это время просто ждёт свой Decision.
Если никто не ответил за отведённое время (по умолчанию 10 минут), вызов запрещается, а сообщение с кнопками правится, чтобы было видно, что ответа не было. Агент получает отказ и продолжает работу с его учётом, а не висит бесконечно.
Ограничения Telegram
Агенты пишут Markdown, а Telegram понимает только небольшое подмножество HTML: жирный, курсив, код, ссылки, <pre> и <blockquote>. Заголовков, списков и таблиц нет. Поэтому ответ агента разбирается через pulldown-cmark и пересобирается: заголовки становятся жирным текстом, пункты списков — строками с маркерами, а таблицы — строками «ключ: значение». Широкие таблицы на телефоне читаются плохо, их строки превращаются в карточки: первая ячейка — заголовок, остальные — подписанные поля.
Сообщение в Telegram ограничено 4096 символами. Длинный ответ режется на части по границам строк, причём уже после экранирования: нельзя разрезать & пополам, иначе Telegram отклонит всё сообщение.
Даже после этого Telegram иногда отказывается разбирать разметку. Терять ответ агента из-за ошибки форматирования нельзя, поэтому в таком случае сообщение отправляется ещё раз обычным текстом. На ограничение частоты (RetryAfter) хаб делает одну повторную попытку, но ждёт не больше минуты, чтобы агент не простаивал из-за Telegram.
Перенос сессий
Отдельная история — команды /save и /restore, которые сохраняют сессию и продолжают её в другой теме. Казалось бы, достаточно запомнить ID сессии, но историю каждый агент хранит по-своему: у Claude это projects/<проект>/<id>.jsonl, у Codex — файлы rollout-…-<id> в sessions/, у Qwen и Hermes свои форматы. Поэтому у каждого агента своя реализация экспорта и импорта за общим трейтом SessionArchive. В снимок попадает только история самого агента, без файлов проекта и без данных входа.
Безопасность
Хаб запускает агентов на моём компьютере от моего пользователя, поэтому о безопасности пришлось думать с самого начала.
— Стороннего сервера нет: приложение само ходит в Bot API и запускает CLI локально.
— Бот принимает сообщения только из одной группы и только от пользователей из списка, остальное отклоняется и пишется в лог.
— Что спрашивать, а что нет, решают режим разрешений и настройки самого агента. «Разрешить всегда» хаб не выбирает.
— Рабочую директорию нельзя увести за пределы заданного корня, в том числе через симлинки. Файлы агент может прислать только из своей директории.
— API-ключи хранятся в системном хранилище ключей, а не в файле настроек. Токены входа агентов хаб не читает.
Главный риск остаётся: доступ к Telegram-аккаунту означает доступ к агентам. Так что без двухфакторной защиты в Telegram таким инструментом лучше не пользоваться.
Почему Rust
Один бинарник без рантайма для Windows, macOS и Linux, предсказуемое поведение процесса, который работает сутками, и строгие проверки компилятора и clippy. Сейчас в проекте около 33 тысяч строк, сборки для всех платформ собираются в GitHub Actions.
Итого
Самым полезным решением оказался узкий трейт между агентами и мессенджером. Протоколы у агентов разные и меняются, но всё, что нужно от человека, укладывается в три вопроса: можно ли выполнить, что ответить и куда отправить файл.
Код открыт под MIT: github.com/aprazdnikov/agent-hub-rs. В планах английская версия и поддержка других агентов.
А как вы работаете с агентами, когда отходите от компьютера?
ссылка на оригинал статьи https://habr.com/ru/articles/1100578/