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. В планах английская версия и поддержка других агентов.
А как вы работаете с агентами, когда отходите от компьютера?
Автор: aprazdnikov
