Протокол межсессионного взаимодействия
Эта страница описывает контракт для программы, которая хочет участвовать в межсессионном обмене сообщениями, не будучи сессией Qwen Code: голосовой фронтенд, ретрансляционный демон, скрипт, наблюдающий за сборкой. В ней описано, что сессия записывает в реестр, что её inbox считывает из подключения и что она отправляет в ответ. Всё нижеизложенное отражает текущее поведение кода при версии схемы 1 и версии фрейма 1; последний раздел описывает, что может измениться и как вы об этом узнаете.
Каждое значение, пересекающее границу процесса, считается недоверенным при получении и проверяется читателем. Если на этой странице сказано, что поле «должно» иметь определённую форму, значение, не соответствующей форме, отбрасывается, но никогда не отклоняется с ошибкой.
1. Реестр сессий
Запущенная сессия публикует одну запись:
$QWEN_HOME/sessions/<pid>.json (каталог 0700, файл 0600)
$QWEN_HOME/sessions/<pid>-<8 hex>.json (процесс, размещающий несколько сессий)$QWEN_HOME по умолчанию ~/.qwen. Имя файла привязано к PID
записывающего процесса — либо просто PID, либо PID, дефис и восемь строчных
hex-символов, выпущенных при регистрации (см. «Несколько записей от одного
процесса» ниже). Запись, у которой поле pid не совпадает с префиксом PID
в имени файла — сравнение выполняется в канонической десятичной форме,
поэтому имя с нулевым дополнением не совпадает ни с чем, — игнорируется.
{
"schemaVersion": 1,
"pid": 41337,
"procStart": "a1b2c3d4-…-boot-uuid:8895124",
"pidNs": 4026531836,
"sessionId": "8e016be8-5b48-4c13-ad22-1f5326ae64ac",
"cwd": "/home/me/project",
"name": "project-3f",
"startedAt": 1788959000000,
"qwenVersion": "0.23.0",
"kind": "tui",
"ipcPath": "/run/user/1000/qwen-socks/41337.sock",
"ipcToken": "c0ffee…64 hex…"
}| Поле | Значение |
|---|---|
schemaVersion | Всегда 1. Читатель пропускает запись с более высокой версией и никогда её не удаляет. |
pid | Идентификатор процесса записывающего. Должен совпадать с PID, к которому привязано имя файла: полное имя для простой формы, цифры перед суффиксом -<8 hex> для выпущенной. |
procStart | <boot id>:<process start ticks> на Linux (/proc/sys/kernel/random/boot_id и поле 22 из /proc/<pid>/stat); null на остальных системах. Защищает от повторного использования PID и от записей, созданных на другой машине с общим домашним каталогом. |
pidNs | Номер иноды /proc/self/ns/pid на Linux; null на остальных системах. Читатель просматривает и очищает только записи из собственного пространства имён. |
sessionId | Идентификатор сессии. /clear и /resume заменяют его под тем же PID, поэтому перечитывайте запись перед каждой отправкой. |
cwd | Рабочий каталог на момент регистрации. |
name | Отображаемое имя. Выводится из базового имени cwd (буквы Unicode, диакритические знаки, цифры, ., _, -; до 32 кодовых точек) плюс - и первые два hex-символа из sha256(sessionId), если записывающий не задал своё имя. Не является уникальным. |
startedAt | Миллисекунды с эпохи. Порядок списка — от новых к старым; используется как критерий разрешения ничьей между близнецами. |
qwenVersion | Произвольный текст или null. |
kind | Чем зарегистрировалась: tui (кто-то за терминалом), headless, serve, external. Строчные ASCII-буквы, цифры и дефисы, не более 16 символов; всё остальное отбрасывается при чтении. Отсутствие означает, что записывающий старше этого поля, которое читается как tui. Метка для списков — никогда не учётные данные; см. ниже. |
ipcPath | Сокет inbox, присутствует только пока он привязан. Отсутствие означает, что сессия обнаружима, но недоступна для отправки сообщений. |
ipcToken | 64 hex-символа. То, что подключение к ipcPath предъявляет в строке аутентификации. Отсутствие означает, что inbox не требует аутентификации (записи от старых сборок). |
Запись — это самоотчёт. Каждое поле в ней записано процессом, который
она описывает, поэтому name, cwd и kind — это утверждения, а не факты,
на которые читатель может опереться. Ничто, определяющее, что отправитель
может делать, не читает их — это определяется тем, что предъявляет
подключение (§3), и собственной политикой принимающей сессии (§6). Задавайте
kind так, чтобы список мог честно группировать сессии; не рассчитывайте,
что это вам что-то даст.
Запись собственной записи. Внешний процесс, который хочет быть
обнаруженным — отображаться в qwen sessions ps, быть адресатуемым через
send_message, способным получать подтверждения, — записывает для себя
такую же запись: собственный pid, procStart и pidNs, вычисленные тем же
способом, sessionId, который он сам генерирует (любой UUID), kind: "external", name (своё или выведенное тем же способом; оно сворачивается в
одну строку и ограничивается при отображении), и ipcPath + ipcToken для
inbox, который он сам привязывает (§2). Записывайте во временный файл в том
же каталоге и делайте rename поверх целевого; создавайте файл с правами
0600; отказывайтесь писать через symlink. Удаляйте запись при выходе.
Запись, чей процесс завершён, очищается следующей сессией, которая выполняет
список, но только когда procStart доказывает, что PID не просто повторно
используется.
Чтение. Всё, что может читать каталог, может читать каждую запись,
включая токены: возможность обнаружить сессию и возможность аутентифицироваться
в ней — это одна и та же возможность (capability) по замыслу. Не выводите
ipcToken туда, где его может увидеть модель или лог.
Актуальность. Запись считается актуальной, когда выполняются все условия:
имя файла — <pid>.json или <pid>-<8 hex>.json, и его префикс PID равен pid;
pidNs совпадает с читателем; boot id внутри
procStart совпадает с читателем (или procStart равен null); и PID жив с
теми же тиками запуска. Актуальная запись с ipcPath всё равно должна быть
набрана, прежде чем она будет объявлена достижимой — файл сокета
переживает сбой.
Ссылки. Отображаемые идентификаторы используют ref = sha256(sessionId)[0:6]. Две
сессии могут иметь общее имя name; грамматика адреса, который вводит
отправитель, — это name, name [ref], [ref] или просто ref, а
неоднозначное name считается ошибкой, а не предположением.
Несколько записей от одного процесса. Любой дочерний процесс qwen --acp — порождённый
демоном или управляемый напрямую редактором или другим клиентом, — записывает
одну запись на сессию, с именем <pid>-<8 hex>.json, начиная с первой
сессии. Суффикс выпускается при регистрации и никогда не меняется; замена
идентификатора сессии под ним — это патч записи, а не её переименование.
Каждая из них несёт одинаковый ipcPath, потому что процесс привязывает один
inbox для всех своих сессий и различает их по toSessionId в каждом фрейме
— поэтому всегда отправляйте toSessionId: фрейм без него, достигший
такого процесса, получает ответ misaddressed, поскольку нет единственной
сессии, которую он мог бы иметь в виду. Актуальность, очистка, проверки
пространства имён и boot id читают запись точно так же, как для простого
имени; отличается только проверка соответствия PID/имени файла, и только в
сравнении pid с цифрами перед суффиксом, а не с полным именем.
2. Сокет inbox
Один UNIX domain socket на сессию, по первому работающему варианту из следующих:
$XDG_RUNTIME_DIR/qwen-socks/<pid>.sock$TMPDIR/qwen-socks-<16 hex>/<pid>.sock/tmp/qwen-socks-<16 hex>/<pid>.sock
Каталог имеет права 0700, сокет — 0600. Путь длиннее 103 байта
пропускается. Если имя с ключом PID уже занято активным слушателем (два
пространства имён PID разделяют runtime-каталог), сессия привязывает
<pid>-<8 hex>.sock рядом с ним. Пиры никогда не выводят путь сокета
самостоятельно; они читают ipcPath из записи.
Подключение передаёт JSON с разделением по строкам, один объект на строку, UTF-8. Одна строка длиннее 1 МиБ (измеряется в кодовых единицах UTF-16) разрывает подключение. Подключение, которое не завершило строку, поддающуюся разбору, за 30 секунд, разрывается; мусорные строки не продлевают крайний срок. Слушатель принимает не более 64 подключений одновременно.
Ожидаемый обмен — одно сообщение на подключение: подключиться, записать
строку аутентификации и фрейм одним записыванием, half-close, ждать, пока
пир закроет. Получатель никогда не пишет в том же подключении; всё, что ему
нужно сказать, возвращается отдельным подключением к вашему собственному
ipcPath.
3. Строка аутентификации
Когда целевая запись имеет ipcToken, первая строка должна быть:
{ "msgV": 1, "type": "auth", "token": "<token>" }Принимаются три вида токенов, и inbox запоминает, какой именно был предъявлен:
| Предъявлено | Inbox делает вывод | Эффект |
|---|---|---|
ipcToken из записи реестра цели | обычный пир | подчиняется политике и согласованности режима (§6) |
QWEN_CODE_MESSAGING_TOKEN из собственного окружения цели | процесс, запущенный этой сессией | доставляется в рамках согласованности по умолчанию; origin="own-process" |
Токен контроллера qpc_<64 hex>, выпущенный через qwen sessions controllers add | программа, которой доверяет пользователь | доставляется в рамках согласованности по умолчанию; origin="controller" с меткой гранта |
Первая строка, не являющаяся строкой аутентификации, или предъявляющая токен,
не соответствующий ни одному из трёх видов, тихо разрывает подключение. Когда
запись не имеет ipcToken, не отправляйте строку аутентификации; старый
inbox прочитает её как неизвестный тип фрейма и пропустит, так что
безопасно не отправлять её в любом случае.
Ничто здесь не аутентифицирует отправителя: токен доказывает, что
подключение разрешено, а не кто его открыл. from, fromName, fromMode и
каждое поле записи — это утверждения.
Это также полная модель доверия. Программа, которой пользователь хочет
управлять своими сессиями, получает токен контроллера, выпущенный вручную и
переданный именно этой программе; именно это определяет разницу между
сообщением, которое доставлено, и сообщением, которое ожидает проверки.
Запись kind: "external" или знакомое name ничего не даёт.
4. Пользовательский фрейм
{
"msgV": 1,
"msgId": "5f1d0c9e-3b2a-4e8f-9c7d-1a2b3c4d5e6f",
"type": "user",
"from": "/run/user/1000/qwen-socks/40011.sock",
"replyToken": "<my own ipcToken>",
"fromName": "project-3f",
"fromMode": "prompting",
"toSessionId": "8e016be8-…",
"priority": "next",
"message": { "role": "user", "content": "build finished, 0 failures" }
}| Поле | Правило |
|---|---|
msgV | Число. Должно быть ≤ 1; более высокое отбрасывается. |
msgId | ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$, не должен канонизироваться (удаление дефисов, приведение к нижнему регистру) в all. Используйте свежий UUID для каждого сообщения: получатель запоминает идентификаторы, по которым уже вынесено решение, и повторяет старый вердикт для повторно отправленного. |
type | "user". |
from | Ваш ipcPath, если есть. Куда отправлять подтверждения. Отсутствие означает отсутствие подтверждений. |
replyToken | Ваш ipcToken, чтобы получатель мог аутентифицировать свои подтверждения перед вами. |
fromName | Отображаемое имя; сворачивается в одну строку, не более 200 символов. |
fromMode | "prompting" (человек проверяет каждое действие) или "bypass" (некоторые действия применяются без проверки). Отсутствие означает «не утверждает ничего», что интерпретируется как ожидание проверки (§6). |
toSessionId | sessionId, который вы прочитали из записи. Получатель, у которого другой идентификатор, отвечает misaddressed. Всегда отправляйте его. |
priority | "now" или "next"; всё остальное читается как "next". Сохраняется для будущего пути прерывания; сегодня получатель ставит оба в очередь на следующий ход. |
message | role должен быть "user"; content — непустая строка. |
Неизвестные поля игнорируются.
5. Фрейм статуса доставки
Получатель сообщает о судьбе сообщения одним управляющим фреймом на каждый
результат, отправляемым на from сообщения и аутентифицируемым с помощью
его replyToken:
{
"msgV": 1,
"msgId": "<fresh id>",
"type": "control",
"action": "delivery_status",
"status": "held",
"origMsgId": "5f1d0c9e-…",
"from": "/run/user/1000/qwen-socks/41337.sock",
"reason": "Your message is held for the recipient user to review …"
}status | Когда | Что делать |
|---|---|---|
held | Отложено для проверки пользователем. Повторяется при повторной отправке и при освобождении, которое не удалось поставить в очередь. | Ждать; далее следует решение или истечение срока. |
delivered | Поставлено в очередь для модели. | Ничего. Не является доказательством прочтения. |
denied | Человек проверил и отклонил. | Не отправлять повторно. |
refused | Политика сессии отклоняет сообщения от пиров; никто его не видел. Всегда только первое подтверждение. | Остановиться; связаться с этим пользователем другим способом. |
expired | У отложенного сообщения истёк срок ожидания, сессия завершилась, не прочитав его, или оно прибыло во время завершения этой сессии. Может следовать за held или delivered. | Повторно отправить позже, если всё ещё актуально. |
misaddressed | toSessionId не совпадает с сессией по этому адресу. | Перечитать реестр. |
dropped | Inbox отклонил его до срабатывания какой-либо политики (§6). | Считать неотправленным. Не повторять в цикле; включить важное в одно позднее сообщение. |
Подтверждение dropped содержит ещё два поля. dropReason — это
rate-limited, duplicate или queue-full. droppedMsgIds перечисляет до
256 дополнительных идентификаторов, по которым то же подтверждение выносит
решение: на пакет отвечает одним подтверждением, а не по одному, поэтому
отправитель переводит каждое потерянное сообщение в терминальное состояние
из одного фрейма. Оба поля не имеют смысла при любом другом статусе и там
игнорируются.
reason — это произвольный текст для человека. Порядок подтверждений не
gарантирован между подключениями; применяйте их как переходы состояния:
pending → held | delivered | denied | refused | expired | misaddressed | dropped
held → delivered | denied | expired | misaddressed
delivered → expired | misaddressedВсё остальное — повтор и должно игнорироваться. Подтверждение для
идентификатора, который вы никогда не отправляли, — это шум; игнорируйте его.
Подтверждения отправляются по мере возможности (best-effort) на стороне
получателя: исчерпанный исходящий лимит или мёртвый from теряет их без
уведомления, поэтому отправитель должен допускать, что ответ не придёт
никогда.
Ваш собственный inbox получает эти фреймы от сессий, которым вы отправляли
сообщения. Если вы только отправляете, всё равно привяжите inbox и указывайте
from: без него вы слепы ко всем результатам выше.
6. Что получатель делает с сообщением
По порядку:
- Допуск. На каждого отправителя: пакет из 30, затем одно сообщение каждые две секунды. Все отправители вместе: пакет из 32, затем одно сообщение в секунду — отправитель указывает своё имя в фрейме, поэтому ротация этого имени даёт новый лимит по первому ограничению, но не по второму. То же тело сообщения от другой сессии в течение 30 секунд считается
duplicate; процесс, запущенный сессией, и доверенный контроллер освобождены от этой проверки, но ограничены по скорости, как и все остальные. Отброшенное сообщение никогда не откладывается, никогда не доставляется и не оставляет следа, поэтому отправитель, дождавшийся окончания своего пакета и повторивший отправку, всё равно проходит. - Обработанные идентификаторы.
msgId, по которому гейт уже принял решение, повторяет свой предыдущий вердикт. - Политика.
agents.crossSessionInboundсо значениемaccept,holdилиrefuseпобеждает. Если не установлено: процесс, запущенный сессией, или доверенный контроллер принимается; в противном случае сообщение принимается только когдаfromModeназывает тот же класс проверки, в котором находится получатель, и откладывается во всех остальных случаях, включая отсутствиеfromMode. - Удержание. До 50 сообщений ожидают. Сообщение, прибывшее при полном буфере, отбрасывается с
queue-full, а не вытесняет уже отложенное. Удерживаемое сообщение истекает черезagents.crossSessionHeldExpiry(1m,5m,10m,never; по умолчанию5m). Пользователь освобождает или отклоняет через/peers; смена режима пересматривает накопившиеся. - Очередь. Принятое сообщение попадает во входную очередь сессии, которая вмещает не более 50 от пиров. Полная очередь также отбрасывается с
queue-full.
Отправителю не обязательно выявлять ограничения опытным путём: сессия Qwen Code зеркалирует их для каждого адреса и отклоняет собственную отправку до записи, предлагая своей модели пакетировать сообщения.
Модель видит доставленное сообщение как:
<cross_session_message from="/run/user/1000/qwen-socks/40011.sock" name="project-3f">
build finished, 0 failures
</cross_session_message>за которым следует уведомление, указывающее полномочия отправителя.
origin="own-process" или origin="controller" controller="<label>"
добавляется получателем из того, что предъявило подключение, а не из фрейма;
метка контроллера берётся из гранта, выпущенного пользователем, а не из
fromName. Теги, похожие на Envelope, обезвреживаются внутри content.
7. Совместимость
- Читатель игнорирует неизвестные поля. Добавление поля в запись или фрейм не является ломающим изменением.
schemaVersionиmsgVповышаются только при изменении формы существующих полей. Читатель отбрасывает фрейм или пропускает запись с версией выше известной ему и никогда не удаляет такую запись.- Новые значения
statusмогут появляться; относитесь к неизвестному как к «нет перехода» и продолжайте ждать. То же самое касаетсяkind, который вы не распознаёте: показывайте его, не исправляйте. - Константы, которые могут измениться без уведомления: значения пакета и скорости, потолок удержания и варианты истечения, лимит строки 1 МиБ, 30-секундный крайний срок строки, лимит в 64 подключения.
8. Ещё не решено
- Уступка имени. Две сессии в одном каталоге могут зарегистрировать
одинаковое
name; сегодня они различаются только поref. Регистрация, уступающая активному имени, и управляющий фрейм, сообщающий пирам о переименовании сессии, — оба ещё в разработке. - Сообщение об одинаковых именах.
qwen sessions psиlist_agentsне помечают записи, которые всё ещё конфликтуют. - Входящие сообщения для сессий, управляемых через ACP. Сессия, которую
программа управляет через ACP — порождённая демоном или нет, —
регистрируется и может отправлять, но отвечает
refusedна всё отправленное ей: удержание — это вопрос, заданный человеку, а никто не следит за списком удержаний от её имени. Где должно появляться удержанное сообщение для таких сессий — у её клиента или в собственном API демона, — остаётся открытым. - Сессии за одним inbox — один отправитель для каждого пира. Процесс,
размещающий несколько сессий, отправляет с одного адреса
from, поэтому бюджет на отправителя и окно дубликатов (§6) принимающего разделяются всеми сессиями этого процесса одновременно: активный соседний процесс может исчерпать лимит другого, а тело, только что отправленное одному, не может быть повторено его соседу внутри окна. Посессионный учёт требовал бы доверия к полю, утверждённому в фрейме, что модель доверия §3 исключает.