Android-клиент для DeepSeek Harness: как работать с агентом на ПК с телефона без облака и FCM
DeepSeek Harness (дальше — DSH) у меня работает на компьютере, и там же живут агент, его сессии и история, инструменты, подписки и ключи провайдеров. Чтобы добраться до этих чатов с телефона, я написал DSH Mobile — клиент для Android. Проект неофициальный, к DeepSeek отношения не имеет, код открыт под MIT. С телефона видны чаты всех проектов одним списком; агенту можно поставить задачу, приложить к ней фото или файл, ответить на его вопрос, а когда ответ готов, приходит уведомление.
Пишу про текущие версии — приложение 0.8.0 (Android 8.0 и новее, targetSdk 36) и плагин 0.7.0 для компьютера с Windows; из DSH проверены 0.2.0-rc.2 и 0.2.1-alpha.1. Обзора кнопок здесь не будет. Мне интереснее рассказать, как телефон разговаривает с агентом без облачного сервера и что делать с командой, про которую неизвестно, дошла она или нет.
Исходные условия
Телефон в этой схеме — только клиент. Агент, подписки и ключи провайдеров с компьютера никуда не переезжают, облачного сервера и аккаунтов нет. До компьютера телефон достает по домашнему Wi-Fi, через Tailscale или через relay, который владелец поднимает сам, а веб-порт DSH наружу так и не публикуется.
Интерфейс написан нативно, на Kotlin и Jetpack Compose с Material 3, без обертки над веб-страницей. Еще одно правило касается команд, и к нему я буду возвращаться дальше: телефон никогда не повторяет их вслепую. Если доставка не доказана, пользователь видит это на экране.
Скриншот: чаты всех проектов в одном списке

Почему плагин, а не WebView
Самый очевидный путь — открыть на телефоне веб-интерфейс DSH. Тогда пришлось бы выставлять его порт в сеть и как-то переносить на телефон браузерную авторизацию. В установленном DSH 0.2.0-rc.2 браузер авторизуется cookie, привязанной к адресу хоста и выведенной из токена запуска; отдельного механизма для мобильного клиента я там не нашел. Был еще вариант со stdio SDK, но он создает отдельный рантайм, а мне на телефоне нужны те же чаты, что открыты на компьютере.
Так появился плагин на Node.js. DSH загружает его в свой процесс, и плагин пользуется собственными контроллерами DSH: в 0.2.0-rc.2 ему доступен ctx.sessionController с операциями list, create, prompt, cancel, page и follow. Веб-интерфейс он не разбирает, токен браузера не экспортирует, своего агентного рантайма не запускает, а наружу отдает узкий HTTPS API для привязанных устройств.
Схема: телефон, плагин и DSH
Android: UI / ViewModel / репозиторий
│ HTTPS + SSE с авторизацией (Wi-Fi, Tailscale или свой relay)
Плагин DSH Mobile: привязка, права, журнал команд, проекция чатов
│ прямой вызов контроллеров внутри процесса
DSH: сессии, агент, хранение, инструменты, подписки
Сессии, сообщения, вызовы моделей, история и права инструментов так и остаются за DSH. Плагину достались привязанные устройства, одноразовые приглашения, отзыв доступа и квитанции команд — небольшой явный набор операций, никакого универсального RPC-прокси. Телефон отвечает за интерфейс, хранит учетные данные и черновики и показывает, в каком состоянии доставка. Закрытый экран означает только конец наблюдения, работу на компьютере он не отменяет никогда.
Клиент зависит от собственного версионированного контракта /v1, а не от внутреннего формата DSH. Поменяются внутренности DSH — переделывать придется плагин. Адаптер в плагине включается лишь тогда, когда владелец явно указал версию DSH из списка проверенных, с остальными он работать отказывается. Это именно объявление владельца, автоопределения нет, так что после обновления DSH совместимость надо проверять заново. Проекты плагин берет из реестра рабочих пространств DSH в том же порядке, что и в боковой панели (учитываются первые 100), поэтому новый проект появляется на телефоне без повторной привязки.
Флаги возможностей
Приложение и плагин обновляются независимо друг от друга, поэтому каждую новую функцию плагина 0.7.0 я объявил отдельным флагом рядом с hostVersion в GET /v1/capabilities. Это steer, queue, rename, models, questions, questionSkip, attachments, fileDownload и отдельный блок для уведомлений. Старые версии приложения флаги просто игнорируют. Новые прячут то, о чем плагин не сообщил, поэтому приложение 0.8.0 с плагином 0.6.0 работает без новых функций и не показывает кнопок, которые не сработают. Протокол так и остался /v1, изменения в нем только добавляют возможности.
Как читать чат и ничего не запустить
С follow в DSH 0.2.0-rc.2 есть подвох, который легко пропустить. Операция начинается со снимка чата, но после первого кадра может активировать неактивную сессию. Получается, что простой просмотр чата с телефона мог бы запустить выполнение. Поэтому плагин берет только первый кадр и сразу закрывает итератор, никогда не продвигая его ради наблюдения, а дальше собирает состояние из безопасных снимков, подписок на события хоста и периодического обновления.
Сам чат приходит на телефон по SSE. Сначала прилетает полный, но ограниченный снимок: до 100 последних сообщений и не больше 2 МиБ на кадр. После существенных изменений приходят новые снимки, в паузах — heartbeat-комментарии, не реже раза в 20 секунд. Если связь рвется, клиент открывает новое наблюдение и целиком заменяет картину свежим снимком. Делать вид, что курсор событий переживет любой перезапуск плагина или хоста, я не стал.
Отдельная история — длинные чаты после компактизации. Свежие сообщения там могут ссылаться на более раннюю замену, и чтобы правильно показать последние сообщения, плагину иногда приходится читать старые записи журнала. Он идет назад через sessionController.page, но не дальше 16 страниц и 16 МиБ. До 0.7.0, если этого не хватало, плагин отдавал пустой чат. Это было безопаснее, чем показать ответ, который DSH, возможно, уже отбросил. Сейчас он возвращает достоверный хвост последних сообщений с пометкой, что история обрезана, а устаревшие и неоднозначные сообщения скрывает, как и раньше.
Привязка по QR-коду
Привязку начинает владелец на компьютере, локальной командой CLI плагина. Права на чтение и на запуск задач выдаются раздельно: с --execute all телефон сможет еще и ставить задачи. Команда выглядит так:
node $cli pair --config $config --read all --ttl 300 --qr
На выходе получается приглашение: JSON с адресом компьютера, одноразовым токеном и SHA-256-отпечатком открытого ключа (SPKI) сертификата. По желанию туда кладется и сам сертификат как явный якорь доверия. Формат целиком — под спойлером.
Формат приглашения
{"version":1,"baseUrl":"https://computer.example:9443","pairingToken":"<one-use-secret>","pinSha256":"sha256/<base64-SPKI-sha256>","certificatePem":"<optional-local-trust-anchor>"}
Чтобы все это поместилось в QR-код, JSON сжимается zlib (DEFLATE), кодируется в base64url без выравнивания и получает префикс dshm1:. Декодер на телефоне я сделал придирчивым. Он смотрит на размеры еще до распаковки, требует один полный поток с верной контрольной суммой и строгий UTF-8, а «сырой» DEFLATE, gzip, лишние байты в конце и JSON с вложенностью глубже 32 отбрасывает до рекурсивного разбора. Если приглашение в QR-код все-таки не помещается, команда с одним --qr завершается ошибкой qr_too_large — обрезать его хост не будет. Тогда остается приватный JSON-файл, и то только при явном --output <путь> внутри каталога invitations рядом с конфигурацией.
Сканер обходится без сети: CameraX и ZXing, никаких сервисов Google Play и телеметрии. Разрешение на камеру он просит только после нажатия на сканирование, кадры и содержимое кода не сохраняются и никуда не уходят. Отсканировать код — еще не значит привязаться. Приложение сначала показывает адрес компьютера и отпечаток сертификата, и привязка начинается только после подтверждения.
Токен приглашения на стороне хоста — не меньше 256 случайных бит. Он одноразовый, живет не дольше 15 минут, и хранится от него только хеш. После привязки телефон получает свой токен устройства, и от него на компьютере тоже остается только хеш. На телефоне учетные данные защищены Android Keystore и в резервные копии не попадают. От одного сценария пин не спасает: если подменить приглашение целиком, вместе с адресом и отпечатком, он ничего не заметит. Поэтому QR-код — секрет, в чаты его не отправляют и в общие журналы не сохраняют.
TLS и права доступа
Рабочая сборка разговаривает с компьютером только по HTTPS. Срок действия сертификата и имя хоста проверяются, а сверху обязателен SPKI-пин из приглашения. Закрепленную личность компьютера телефон проверяет раньше, чем отправит секрет привязки. Незашифрованный HTTP есть только в отладочных сборках и только для 127.0.0.1 и localhost, чтобы работал эмулятор через adb reverse. По умолчанию плагин слушает лишь loopback, а для сетевого интерфейса ему нужна явная настройка TLS.
Права на чтение и запуск проверяются на каждом запросе, а пока поток открыт — еще и периодически. Отзыв устройства или потеря права на чтение проекта закрывают открытые потоки на очередной проверке, обычно в пределах секунды. С правом на запуск иначе: если отнять только его, поток живет дальше, а телефон получает новый снимок с canExecute:false. Команду, которая уже ушла агенту, ни отзыв, ни сужение прав задним числом не отменяют.
Тут нужна оговорка: фильтр по проектам — не песочница. Плагин работает внутри DSH с его правами, и агент, запущенный с телефона, может то же, что и запущенный с компьютера. Потерянный телефон отзывают на компьютере. Выход из приложения стирает данные привязки только на самом телефоне.
Вне дома есть два пути. Можно поставить Tailscale на оба устройства — тогда порт на роутере открывать не нужно. Можно поднять собственный relay на VPS: он пересылает зашифрованный TLS-трафик, а токен устройства и содержимое чатов остаются внутри TLS-соединения между телефоном и плагином. Видеть relay при этом будет IP-адреса, идентификаторы маршрута и доступа, время и объем передачи, возможно — метаданные TLS-рукопожатия, и отказать в обслуживании он тоже может. Общего публичного relay у проекта нет.
Квитанции команд вместо повторов
Представьте, что телефон отправил задачу, а ответ потерялся по дороге. Наивный клиент повторит запрос, и агент получит ту же задачу второй раз. Чтобы так не было, на компьютере ведется журнал квитанций в SQLite.
У каждой команды есть UUID requestId. Его создает телефон и сохраняет вместе с черновиком еще до отправки. Хост записывает устройство, requestId, операцию, хеш нормализованного содержимого и статус, фиксирует dispatching и только потом вызывает DSH. На точный повтор, пусть даже одновременный, хост возвращает ту же квитанцию и DSH второй раз не вызывает. Тот же requestId с другим содержимым получает 409.
Дальше все зависит от того, чем кончился вызов. Известный отказ DSH превращается в ошибку 4xx и статус квитанции rejected — так, конфликт очереди дает 409 conflict. Хуже, когда исход неизвестен: после вызова DSH вылетела неожиданная ошибка или процесс упал прямо в момент отправки. Такая квитанция получает статус uncertain, и повторно хост эту команду не отправит ни сразу, ни после перезапуска.
На телефоне таймаут тоже не считается ошибкой. После переподключения приложение запрашивает исходную квитанцию и перечитывает чат. Неопределенную доставку оно показывает явно, а отказаться от такой команды можно только вручную и после предупреждения, что работа уже могла выполниться.
Гарантии «ровно один раз» я не даю: для нее пришлось бы доказать поведение на границе с хранилищем DSH. Зато слепых повторов нет, и неопределенность видна пользователю. Для сообщений плагин 0.7.0 передает в DSH идентификатор, производный от requestId и отдельный для каждого устройства, так что два телефона с совпавшим requestId не столкнутся. И стоит помнить, что accepted в квитанции доказывает только прием команды, а не завершенную работу.
Очередь, «Направить» и смена модели
Агент работает ходами, ход состоит из шагов. Если написать ему, пока он занят, сообщение по умолчанию уйдет в собственном режиме DSH queue, встанет в очередь и попадет к агенту в следующем ходе. Есть и режим DSH steer, в приложении он называется «Направить»: агент увидит сообщение уже на следующем шаге, а текущий ход при этом не прерывается. Включается он долгим нажатием на кнопку отправки или через ↳ у сообщения в очереди. Сообщение из очереди можно и удалить, удаляется оно на компьютере.
Как на самом деле было доставлено отправленное сообщение, плагин сообщает лишь тогда, когда это доказывает журнал DSH. Вставка в следующий ход значит очередь, вставка в следующий шаг — «Направить». Если доказательства в ограниченной истории нет, поля delivery нет, и клиент не принимает это за очередь. Похожим образом устроена смена модели. Новая модель применяется, когда DSH строит следующий запрос к модели, то есть, возможно, уже на следующем шаге текущего хода, но запрос, который выполняется прямо сейчас, не переключится.
Скриншот: выбор между «Направить» и очередью

Вложения
К сообщению можно приложить снимок с камеры, фото из системного Photo Picker или файлы через системный выбор файлов. На компьютер они начинают загружаться сразу после выбора. Ограничения для пользователя — до 20 вложений и 200 МиБ на сообщение, до 20 МиБ на файл.
Устроено это в две фазы. Пока сообщение не отправлено, загрузка временно лежит на компьютере и привязана к устройству, чату и проекту. Не отправили за 10 минут — загрузка истекает; очистка проходит каждые 30 секунд, так что брошенные загрузки до DSH не доходят. Изображения (PNG, JPEG, WebP, GIF) сервис вложений DSH проверяет еще при загрузке, и агенту они уходят как изображения.
С остальными файлами сложнее. Они ждут в приватном каталоге плагина, который лежит вне любого проекта: имена случайные, доступ только у владельца. В хранилище вложений DSH такой файл передается только в момент отправки, прямо перед сообщением, а в рабочую папку проекта не попадает вообще. Временные файлы стираются после отправки, по истечении срока, при остановке плагина и при старте, а загрузки отозванного устройства удаляются в течение 30 секунд или при следующем запросе.
Резерв под все файлы сообщения делается до того, как команда принята в журнал. Если исчерпан лимит одновременных отправок с вложениями или резерв, запрос получает 429 еще до создания квитанции. Загрузки при этом никуда не деваются и ждут повтора с тем же requestId, но сам по себе ничего не переотправляется.
Работает и обратное направление. Картинки из сообщений видны прямо в чате и открываются в полноэкранном просмотре, файлы можно скачать (до 20 МиБ). Для скачивания хватает права на чтение, но только для вложений из сообщений, которые есть в снимке, действительно полученном телефоном.
Скриншот: фото и файл в сообщении, сообщение в очереди

Вопросы агента
Агент может спросить пользователя через инструмент DSH ask_user_question. На телефоне в ответ можно выбрать один или несколько вариантов, написать что-то свое или пропустить вопрос; если пропустить всю группу, по каждому вопросу уйдет пустой выбор. Отправляется все это через сервис вопросов DSH. Вариант, который агент рекомендует, помечен, но заранее не выбран. Распознает его плагин так же, как DSH на компьютере, — по суффиксу «(Recommended)» или «(推荐)» в тексте варианта, а сам текст возвращает без изменений.
Случается, что агент уже пошел дальше без ответа, а вопрос в DSH все еще открыт. Ответ тогда все равно уходит через сервис вопросов DSH, и если DSH его принимает, никакой ошибки нет. А 409 question_expired плагин вернет, когда вопрос больше не ожидает ответа или ответить на него уже нельзя. Подтверждения действий агента с телефона не сделать: DSH не дает к ним доступа плагинам, так что они остаются на компьютере.
Скриншот: вопрос агента на телефоне

Уведомления без FCM
Google Push (FCM) в проекте нет, и обещать «вечный» фоновый сокет я тоже не хотел. Поэтому фоновые уведомления — явная возможность, и включает их пользователь сам. Включил — приложение запускает foreground-службу с постоянным видимым уведомлением «DSH Mobile на связи», а невидимого фонового сокета нет.
Для уведомлений плагин ведет на компьютере свою ленту в той же базе SQLite, и к SSE конкретного чата она отношения не имеет. В ней только метаданные: идентификаторы события, проекта и чата, время и тип (answer-finished, attention-needed, attention-cleared). Ни заголовков, ни текста переписки, ни аргументов там нет, а каждую страницу ленты фильтруют текущие права устройства на чтение. Телефон держит один поток notification-events/stream с курсором: страницы событий приходят с id, равным следующему курсору, heartbeat — каждые 15 секунд, после переподключения чтение продолжается с курсора. События хранятся 7 дней.
Каждое завершение хода хост сверяет с доказательством зафиксированного ответа и не додумывает его из неразрешенной истории. Еще у ленты есть статус покрытия. После разрыва или переполнения она переходит в degraded, а обратно в ready возвращается только после полного чистого прохода восстановления, без отложенных повторов, переполнения, необработанных событий и новых ошибок. От этого статуса зависит предупреждение «Часть событий могла быть пропущена»: приложение его и показывает, и снимает. До 0.8.0 оно висело навсегда после любого разрыва.
В самих уведомлениях минимум информации. «Ответ готов» содержит только названия проекта и чата. Уведомление о вопросе на разблокированном телефоне показывает текст первого вопроса, на экране блокировки — только название чата. Если чат открыт на экране, уведомлений по нему нет. Для режимов «Со звуком» и «Без звука» заведены разные каналы Android, а несколько оповещений собираются в группу.
Минус у этой схемы тоже есть. Android может остановить и foreground-службу, а у некоторых производителей экономия батареи задерживает доставку. После перезагрузки телефона приложение надо открыть заново.
Скриншот: уведомления на телефоне

Транспорт
В плагине 0.7.0 JSON-ответы от 1 КиБ сжимаются gzip, если клиент его принимает, а HTTP-соединения держатся открытыми 60 секунд. SSE не сжимается, лимит JSON по-прежнему 2 МиБ. По Wi-Fi и Tailscale списки и чаты стали открываться быстрее, но замеров, которые стоило бы публиковать, у меня нет.
Как это проверялось
Тесты плагина проверяют поведение на публичных границах API и хранилища. Например, приглашение должно выдать учетные данные один раз и больше не сработать. В журнале команд проверяется, что незавершенная отправка после перезапуска становится uncertain и никогда не повторяется, точные повторы ее снова не запускают, а одновременные точные повторы возвращают квитанцию, в том числе для сообщений с вложениями. Еще один случай — один и тот же UUID на двух устройствах должен превращаться в разные идентификаторы для DSH. Для вложений тесты следят, чтобы неудачная отправка удаляла приватный файл, повтор никогда не загружал его в DSH дважды, а временные файлы удалялись по сроку, при отзыве устройства, при остановке и при старте. У вопросов проверяются права, схема ожидающего вопроса, повторы и истечение.
Результаты для релиза 0.8.0 такие. В плагине 0.7.0 241 тест, из них 237 проходят и 4 пропущены; пропущенные — необязательные интеграционные тесты, которые запускаются, только если указать им установленный модуль DSH и OpenSSL. Канареечный тест на изолированной установленной копии DSH проходит. В Android-приложении 0.8.0 — 280 модульных тестов и lint без ошибок, инструментальные тесты — 45 из 45 на эмуляторе Android 11 и 45 из 45 на эмуляторе Android 16.
Релизный APK подписан, подпись и выравнивание проверены. Я поставил его поверх 0.7.1 на свой OnePlus 15 с Android 16: версия 0.8.0, чаты и привязка сохранились. Плагин 0.7.0 работает в моем рабочем DSH с 20 октября 2026 года. В работу его ввел автоматический холодный перезапуск с проверками состояния и откатом, и после перезапуска плагин сообщает обо всех новых функциях. Сам я пользуюсь 0.8.0 со своим DeepSeek Harness каждый день.
Ограничения
Компьютер с DSH должен быть включен, потому что телефон сам агента не запускает. Push через FCM нет, фоновые уведомления держатся на foreground-соединении, а подтверждать действия агента можно только на компьютере. Одно приложение работает с одним компьютером, и полной офлайн-истории в нем нет. Поддерживаются только проверенные версии DSH — 0.2.0-rc.2 и 0.2.1-alpha.1.
Ссылки
Код лежит в репозитории pavel-logachev/dsh-mobile на GitHub. Там же, в папке docs, описаны архитектура, протокол и модель безопасности. Обсуждение проекта идет в сообществе DeepSeek Harness.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.