универсальный мост сообщений между агентскими CLIClaude Code ↔ Codex ↔ Kimi ↔ всё, что умеет MCP — между машинами, через relay, который вы хостите сами.
—выберите сценарий — или посмотрите прогон по умолчанию
как агент ждёт другого
Ожидание без блокировки
В tower нет «повиснуть на сокете и ждать». Ожидание — это договорённость из трёх частей: reply_to связывает вопрос с ответом, durable inbox хранит ответ на диске сколько угодно, а nudge — необязательный будильник для совсем простаивающей сессии. Никто никого не держит.
alice@wsl claude-code · спрашивает и продолжает жить
bob@om3 codex · занят своим ходом
alice → send_message
«миграция доехала? можно ребейзиться на main?» → m_42
alice не ждёт на проводе: получает id, заканчивает ход. В её outbox: queued → delivered — доставлено в inbox боба, но ещё не прочитано.
bob · середина хода
гоняет тесты, 4-я минута tool-вызова… конверт лежит в inbox/bob/ на диске
доставка не врывается в ход: сообщение не прерывает запущенный инструмент и не теряется — оно просто ждёт в файле.
bob → send_message (по другому делу)
ответ инструмента: «отправлено … · пока ты работал, пришло 1 сообщение: [m_42] от alice@wsl: …»
piggyback: каждый send заодно отдаёт непрочитанное. Болтливый агент получает почти push-латентность без всякого push-механизма.
корреляция по id, а не по хрупкому «в ответ на твоё последнее сообщение».
towerd@wsl → tmux-пана alice
[tower] 1 unread message — call check_inbox
nudge (opt-in): alice простаивает — демон печатает строку ей в пану. Это at-most-once подсказка: потерянный nudge — это latency, не потерянное сообщение.
alice → check_inbox
«[m_51] от bob@om3 (ответ на m_42): да: колонка tenant_id, ребейзись смело»
ожидание сомкнулось: alice видит, что это ответ именно на её вопрос, и продолжает с того места.
спросить и ждатьОтправь с текстом «отчитайся reply_to», заканчивай ход. Ответ догонит: piggyback на send, check_inbox на паузе или nudge.
статус длинной задачиМиграция на om3 шлёт «этап 3/7 готов» — сессия-наблюдатель читает у себя, когда удобно. Никто не поллит по ssh.
если ответа нетЭто видно, а не подвисло: tower outbox показывает queued с причиной, TTL переведёт в expired, и отправитель узнает.
оркестрация
Оркестратор в herdr
herdr держит агентов по вкладкам: в каждой — свой CLI в своём git-worktree. tower делает вкладки адресуемыми: диспетчер раздаёт задачи сообщениями, исполнители отчитываются обратно, и всё это — durable-файлы, а не хрупкий скрейпинг чужих терминалов. Одна машина — общение по UDS без сети; разнеси вкладки по om-нодам — тот же код поедет через relay.
herdr1:dispatcher2:codex3:grok4:kimi
claude-codedispatcher@wsl · worktree main
codexcodex-auth@wsl · worktree fix-auth
grokgrok-ui@wsl · worktree ui-panel
kimikimi-tests@wsl · worktree tests
Диспетчер не поллит исполнителей в цикле — он заканчивает ход, а отчёты будят его nudge-ем или догоняют piggyback-ом. Рестартни любую вкладку: её inbox и очереди — файлы на диске, задачи не испаряются. tower outbox в любой момент показывает, какая задача у кого «висит». А петля из двух слишком вежливых агентов гасится rate-limit-ом и дедупом повторов.
рой
Двадцать четыре агента — это те же конверты, только много
Рой не требует нового механизма. Fan-out — это N сообщений, каждое со своим durable-файлом в outbox и своим сквозным ack-ом. Воркеры — headless-сессии (claude -p, codex exec) с тем же tower-mcp: хоть во вкладках herdr, хоть под systemd на om-нодах. Упавшая нода — это queued, а не «потерялось»: очередь доезжает сама, когда нода возвращается.
queued 0working 0done 0/24диспетчер раздаёт задачи волнами
адресация масштабируетсяlist_agents отдаёт весь ростер с relay: worker-01@om3 … worker-24@om7. Раздача — обычный цикл send_message.
сводка без опросаДиспетчер не поллит 24 воркеров: tower outbox — это готовый роллап (кто delivered, кто queued и почему), а отчёты сами будят его nudge-ем.
рой не идёт вразносRate-limit на отправителя, дедуп повторов и потолок непрочитанного: петля из двух чересчур услужливых агентов гаснет сама.
дорожная карта · v0.2 «Swarm Dispatch»
Доставка как пробуждение
Сегодня рою нужен кто-то, кто заранее запустил воркеров. В v0.2 это исчезает: сообщение объявленному, но не запущенному агенту запускает его. Рой материализуется по требованию — а из этого следует набор жёстких механических решений, каждое из которых проверено тремя независимыми моделями.
локальная активация: включена объявлением юнитаудалённая: remote = false по умолчанию
# agents.toml на om3 — команду задаёт машина, не сообщение
[[unit]]
name = "worker-*"
command = ["claude", "-p", "$(cat prompts/worker.md)"]
cwd = "/srv/wt/{{name}}"
max_instances = 8
drain_deadline = "120s"
remote = false# ← удалённый спавн только явно
1seen-set — id сообщения не встречался: это не реплей, путь активации открыт
3registry lock по имени агента — 11 остальных конвертов волны спавна не порождают
4spawn setsid, детач, pid в реестр, запись в аудит-лог
5inbox-drain — воркер монтирует tower-mcp и первым делом зовёт check_inbox
6report — ответ с reply_to, воркер выходит; towerd жнёт exit-код
процессне запущен
inbox/worker-07пусто
outbox отправителя—
нажмите «проиграть», чтобы посмотреть путь сообщения через активацию
launcher, не supervisor
towerd запускает ребёнка detached, пишет pid и жнёт exit-код для наблюдаемости — и никогда не рестартит. Повторный запуск — побочный эффект следующей доставки, а не политика супервизии.
не строим: рестарты, DAG, зависимости, worker leasing
один путь доставки
Текст сообщения никогда не подставляется в argv или env: задача уже лежит в durable inbox, воркер её вычерпывает. Нет второго пути доставки — нет ни инъекций, ни секретов в ps.
не строим: шаблоны {{text}} в команде
шестое состояние: stalled
Воркер запустился, но не вычерпал inbox за drain_deadline — отправителю уходит честный stalled (с exit-кодом, если ребёнок умер). Строго «не забрал сообщение», а не «провалил задачу».
не строим: вывод об успехе задачи
ровно один спавн
Замок по имени агента коалесцирует одновременную волну в один запуск; реплеи ловит 7-дневный seen-set, проверяемый строго до пути активации. thread при этом не получает никакой lifecycle-семантики.
не строим: таймерные эвристики cooldown
что ещё едет в v0.2:поле thread + tower outbox --threadglob-волны worker-*@om3 с экспансией на демоне-отправителеростер объявляет declared-юниты, а не только запущенныхclaude-native nudge через own-child сокетthreat model · подписанные бинарники · conformance-набор
что летит по проводу
Одна JSON-строка. Больше ничего.
Сообщение — это текст, который один агент написал другому. Не история разговора, не файлы. Каждый хоп — вот такой конверт, по строке на кадр: его можно грепать голыми руками.
idСквозной ключ идемпотентности. Приёмник держит seen-set на 7 дней: пересланный дубль получит повторный ack, но не повторную доставку.
from / toagent@machine. Машинную часть relay сверяет с Ed25519-ключом отправителя — подделать нельзя. Имя агента — это имя, не идентичность.
textПросто текст, максимум 64 КиБ. На приёмной стороне никогда не исполняется и не парсится на команды.
ttl_sНе доставлено к ts + ttl — outbox отправителя переходит в expired вместо вечных ретраев.
vproto_version = 1. Новые поля — только опциональные, незнакомые — игнорируются. Есть зарезервированное поле enc под будущее E2E-шифрование.
состояния доставки
Каждое сообщение отвечает за себя
Durability живёт в outbox отправителя — по JSON-файлу на сообщение, на машине, которую контролируете вы. Relay не хранит ничего, поэтому его рестарт не теряет ни одного сообщения. tower outbox показывает ровно одно из шести состояний:
queued
На диске, пересылается с backoff-ом, пока не придёт сквозной ack.
delivered
Принимающий демон положил в inbox адресата и подтвердил.
held
Политика приёмника отложила для оператора. Станет delivered или refused.
refused
Политика сказала «нет» или оператор отклонил. Отправителю сообщат.
no_agent
На целевой машине нет агента с таким именем.
expired
TTL кончился раньше, чем случилась передача.
stalled v0.2
Воркер запустился по сообщению, но не вычерпал inbox за drain_deadline.
queued → delivered | held | refused | no_agent | expired held → delivered | refused (решает оператор) stalled — только про активацию, не про исход задачи
четыре бинарника
Один репозиторий, по одной работе на каждого
Статические Go-бинарники без runtime-зависимостей. Шим — единственное, что видит CLI; демон — единственный владелец состояния; relay — единственный, кто ходит по сети.
tower-mcp
шим — на сессию
stdio MCP-сервер, который монтирует любой агентский CLI. Три инструмента: send_message, check_inbox, list_agents.
Каждый send заодно возвращает непрочитанное — почти push-латентность без push-механики.
хранит: ничего. поднимет towerd, если тот не запущен.
towerd
демон — на машину
Владеет реестром агентов, durable-outbox-ом, inbox-ами, очередью held, политикой и единственным соединением с relay.
Доставка внутри машины идёт по Unix-сокету и не касается сети вообще.
хранит: всё — по JSON-файлу на сообщение.
tower-relay
хаб — self-hosted
Аутентифицирует машины Ed25519-челленджем по authorized_keys, маршрутизирует конверты и ack-и между демонами онлайн, раздаёт ростер.
Намеренно stateless: ни диска, ни очередей, нечего бэкапить.
хранит: ничего. рестарт безопасен по построению.
tower
cli — для вас
agents · send · inbox · outbox · held · approve · deny · doctor
Сторона одобрения default-hold живёт здесь: tower approve m_8f… --persist доставит сообщение и запишет accept-правило для этого отправителя.
хранит: контроль в ваших руках.
модель доверия
Правила SSH, а не церемонии OAuth
Ключ на машину, строка на машину в relay, строка на отзыв. Внутри флота текст сообщений остаётся читаемым на каждом хопе — это и есть отладочная поверхность, так задумано.
Машины аутентифицируются
Демон генерирует Ed25519-ключ при первом запуске. Relay даёт челлендж на подключении и сверяет подпись со списком. tower init печатает вашу строку:
# authorized_keys — весь реестрMdElefiQO62…h+w=wsllOlGyyP73aJ…JZo=om3
Приёмники решают
Упорядоченные правила по верифицированной машине отправителя; побеждает первое совпадение. Нет правила? Локальных принимаем, удалённых — держим для вас: