跨会话协议
本页面描述了一个希望参与跨会话消息传递、但本身不是 Qwen Code 会话的程序所需遵循的契约:例如语音前端、中继守护进程、或监听构建状态的脚本。它描述了一个会话向注册表写入什么、其收件箱从连接中读取什么、以及它如何回复。本文所述内容即 schema version 1 和 frame version 1 下代码的实际行为;最后一节说明了哪些内容可能变更以及如何获知变更。
所有跨越进程边界的值在到达时均视为不可信,由读取方进行校验。当本页面规定某字段”必须”具有某种结构时,不符合该结构的值会被丢弃,而非以错误拒绝。
1. 会话注册表
一个运行中的会话发布一条记录:
$QWEN_HOME/sessions/<pid>.json (目录 0700,文件 0600)
$QWEN_HOME/sessions/<pid>-<8 hex>.json (一个进程托管多个会话)$QWEN_HOME 默认为 ~/.qwen。文件名以写入者的 PID 为键——可以是裸 PID,也可以是 PID、短横线和注册时生成的八个小写十六进制字符(参见下文”一个进程的多条记录”)。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 | 写入者的进程 ID。必须与文件名所键入的 PID 一致:裸形式为整个文件名,生成形式为 -<8 hex> 后缀前的数字。 |
procStart | Linux 上为 <boot id>:<process start ticks>(/proc/sys/kernel/random/boot_id 以及 /proc/<pid>/stat 的第 22 个字段);其他平台为 null。用于防止 PID 复用,以及防止在共享同一 home 目录的其他机器上写入的记录被误读。 |
pidNs | Linux 上为 /proc/self/ns/pid 的 inode 编号;其他平台为 null。读取方只列出和清理来自自身命名空间的记录。 |
sessionId | 会话的 ID。/clear 和 /resume 会在同一 PID 下更换它,因此每次发送前都要重新读取记录。 |
cwd | 注册时的工作目录。 |
name | 显示名称。由 cwd 的 basename 派生(Unicode 字母、标记、数字、.、_、-;最多 32 个码点),加上 - 和 sha256(sessionId) 的前两个十六进制字符,除非写入者自行指定。不保证唯一。 |
startedAt | 纪元毫秒数。列表按最新优先排序,此字段也用于在双胞胎之间打破平局。 |
qwenVersion | 自由文本或 null。 |
kind | 注册类型:tui(终端用户)、headless、serve、external。仅限小写 ASCII、数字和短横线,最多 16 个字符;其他值在读取时被丢弃。缺失表示写入者早于该字段,读取时视为 tui。仅用于列表展示的标签——绝非凭证;见下文。 |
ipcPath | 收件箱 socket,仅在绑定时存在。缺失表示可发现但不可发送消息。 |
ipcToken | 64 个十六进制字符。连接到 ipcPath 时在认证行上出示的内容。缺失表示收件箱不需要认证(来自旧版本的记录)。 |
记录是自报信息。 其中每个字段都由其所描述的进程写入,因此 name、cwd 和 kind 都是声明,而非读取方可以依赖的事实。任何决定发送方权限的逻辑都不会读取它们——这些由连接出示的内容(§3)和接收会话自身的策略(§6)决定。设置 kind 是为了让列表能如实分组会话;不要指望它能为你带来任何特权。
写入你自己的记录。 一个希望被发现的外部进程——可被 qwen sessions ps 列出、可从 send_message 寻址、能接收回执——需要为自己写入相同的记录:自己的 pid、以相同方式计算的 procStart 和 pidNs、自行生成的 sessionId(任意 UUID)、kind: "external"、name(自定义或按相同方式派生;显示时会被展平为单行并受限)、以及自行绑定的收件箱的 ipcPath + ipcToken(§2)。写入同一目录下的临时文件,然后 rename 覆盖目标文件;创建文件时权限为 0600;拒绝通过符号链接写入。退出时移除记录。进程已不存在的记录会被下一个列出操作的会话清理,但只有当 procStart 能证明该 PID 并非仅仅被复用时才会被清理。
读取。 任何能读取该目录的进程都能读取所有记录,包括 token:能够发现会话和能够向其认证在设计上是同一能力。不要在任何模型或日志能看到的地方打印 ipcToken。
存活状态。 当以下条件全部满足时,记录为活跃状态:文件名与 pid 匹配;pidNs 与读取方的一致;procStart 中的 boot id 与读取方的一致(或 procStart 为 null);且 PID 存活并具有相同的 start ticks。带有 ipcPath 的活跃记录在被宣告为可达之前仍需拨通——socket 文件在崩溃后仍会存在。
引用。 显示用的句柄使用 ref = sha256(sessionId)[0:6]。两个会话可以共享同一个 name;发送方输入的地址语法为 name、name [ref]、[ref] 或裸 ref,歧义的 name 会报错而非猜测。
一个进程的多条记录。 任何 qwen --acp 子进程——由守护进程生成,或由编辑器或其他客户端直接驱动——从第一个会话起为每个会话写入一条记录,文件名为 <pid>-<8 hex>.json。后缀在注册时生成且永不改变;底下的会话 ID 被更换只是对记录的修改,而非重命名。它们都携带相同的 ipcPath,因为该进程为所有会话绑定一个收件箱,并通过每帧上的 toSessionId 区分它们——因此始终发送 toSessionId:没有该字段且到达此类进程的帧会被回复 misaddressed,因为没有单一会话可以对应。存活状态、清理以及命名空间和 boot 守卫对记录的读取方式与裸名完全相同;只有 PID/文件名一致性检查不同,且仅在于将 pid 与后缀前的数字比较而非整个文件名。
2. 收件箱 socket
每个会话一个 UNIX 域 socket,按以下优先级选取第一个可绑定的路径:
$XDG_RUNTIME_DIR/qwen-socks/<pid>.sock$TMPDIR/qwen-socks-<16 hex>/<pid>.sock/tmp/qwen-socks-<16 hex>/<pid>.sock
目录权限为 0700,socket 权限为 0600。路径超过 103 字节时跳过。当以 PID 为键的名称已被活跃监听器占用时(两个 PID 命名空间共享一个运行时目录),会话会在其旁边绑定 <pid>-<8 hex>.sock。对端从不推导 socket 路径;它们从记录中读取 ipcPath。
连接承载以换行符分隔的 JSON,每行一个对象,UTF-8 编码。单行超过 1 MiB(以 UTF-16 码单元计量)则断开连接。连接在 30 秒内未完成一行可解析的内容则被断开;垃圾行不会延长截止时间。监听器最多同时接受 64 个连接。
预期的交换方式为每个连接一条消息:连接,在一次写入中发送认证行和帧,半关闭,等待对端关闭。接收方永远不会在同一连接上写入;它要说的任何内容都会作为到你自己的 ipcPath 的独立连接返回。
3. 认证行
当目标记录包含 ipcToken 时,第一行必须为:
{ "msgV": 1, "type": "auth", "token": "<token>" }接受三种 token,收件箱会记住看到的是哪一种:
| 出示内容 | 收件箱判定 | 效果 |
|---|---|---|
目标注册表记录中的 ipcToken | 普通对端 | 受策略和模式对等性约束(§6) |
目标自身环境中的 QWEN_CODE_MESSAGING_TOKEN | 该会话启动的进程 | 按对等性默认值投递;origin="own-process" |
通过 qwen sessions controllers add 生成的控制器 token qpc_<64 hex> | 用户信任的程序 | 按对等性默认值投递;origin="controller" 并附带授权标签 |
第一行不是认证行,或出示的 token 不属于以上三种,则静默断开连接。当记录没有 ipcToken 时,不要发送认证行;旧版收件箱会将其视为未知帧类型并跳过,因此始终以不发送认证行为安全。
此处没有任何内容对_发送方_进行认证:token 证明的是连接被允许,而非谁打开了它。from、fromName、fromMode 以及记录的每个字段都是声明。
这就是信任模型的全部。用户希望驱动其会话的程序会获得一个控制器 token,由手工生成并交给该程序;正是它决定了消息是被投递还是等待审核。写入 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:接收方会记住已裁定的 ID,对重发的消息重复旧裁定。 |
type | "user"。 |
from | 你的 ipcPath(如果有的话)。回执发送地址。缺失表示不收回执。 |
replyToken | 你的 ipcToken,以便接收方可以向你认证其回执。 |
fromName | 显示名称;展平为单行,最多 200 个字符。 |
fromMode | "prompting"(人工审核每个操作)或 "bypass"(部分操作无需审核即可执行)。缺失表示”不作声明”,此时按待审核处理(§6)。 |
toSessionId | 你从记录中读取的 sessionId。持有不同 ID 的接收方会回复 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 | 收件箱在任何策略运行之前就拒绝了它(§6)。 | 视为未发送。不要在循环中重试;将重要内容合并到后续的一条消息中。 |
dropped 回执额外携带两个字段。dropReason 为 rate-limited、duplicate 或 queue-full。droppedMsgIds 列出同一条回执裁定的最多 256 个其他 ID:一批消息用一条回执回复而非每条各一条,因此发送方可以从单个帧中将所有丢失的消息转入终态。这两个字段在其他状态上无意义,会被忽略。
reason 是给人看的自由文本。回执的顺序在不同连接间不保证;将它们作为状态转换来应用:
pending → held | delivered | denied | refused | expired | misaddressed | dropped
held → delivered | denied | expired | misaddressed
delivered → expired | misaddressed其他情况均为重复,应被忽略。你从未发送过的 ID 的回执是噪声;忽略它。回执在接收方是尽力而为的:出站限制已满或 from 已失效都会导致回执被静默丢失,因此发送方必须容忍可能永远收不到回复。
你自己的收件箱从你发送过消息的会话接收这些帧。如果你只是发送,也要绑定一个收件箱并提供 from:否则你对上述所有结果都一无所知。
6. 接收方如何处理消息
按以下顺序:
- 准入。 按发送方:突发 30 条,之后每两秒一条。所有发送方合计:突发 32 条,之后每秒一条——发送方在帧中声明自己的身份,因此轮换名称可以从第一个限制获得新的配额,但不能从第二个限制获得。30 秒内来自另一个会话的相同消息体视为
duplicate;会话自身启动的进程和受信任的控制器免于此检查,但仍与其他人一样受速率限制。被丢弃的消息不会被暂存、不会被投递、也不留记录,因此等待突发窗口过后重试的发送方仍然能成功送达。 - 已裁定 ID。 闸门已裁定的
msgId会重复其先前的裁定结果。 - 策略。
agents.crossSessionInbound设置为accept、hold或refuse时以其为准。未设置时:会话自身启动的进程或受信任的控制器被接受;否则仅当fromMode声明的审核类别与接收方相同时消息才被接受,其他所有情况(包括fromMode缺失时)均被暂存。 - 暂存。 最多 50 条消息等待。在缓冲区已满时到达的消息会被
dropped,原因为queue-full,而非驱逐已暂存的消息。暂存消息在agents.crossSessionHeldExpiry(1m、5m、10m、never;默认5m)后过期。用户从/peers释放或拒绝;模式变更会重新评估积压消息。 - 排队。 被接受的消息加入会话的输入队列,该队列最多容纳 50 条来自对端的消息。队列已满时同样以
queue-full原因dropped。
发送方无需通过试错来发现限制: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。看起来像信封的标签会在 content 中被消除。
7. 兼容性
- 读取方会忽略不认识的字段。向记录或帧添加字段不是破坏性变更。
schemaVersion和msgV仅在现有字段结构发生变化时才会递增。读取方会丢弃版本高于自身所知的帧或跳过记录,且永远不会删除此类记录。- 可能会出现新的
status值;将未知值视为”无状态转换”并继续等待。对于不认识的kind同理:展示它,不要纠正它。 - 可能不经通知即变更的常量:突发和速率数值、暂存上限和过期选项、1 MiB 行长度上限、30 秒行截止时间、64 连接上限。
8. 尚未确定的事项
- 名称让出。 同一目录中的两个会话可以注册相同的
name;目前仅通过ref区分。向活跃名称让出的注册,以及通知对端某会话已更名的控制帧,均尚未实现。 - 同名报告。
qwen sessions ps和list_agents不会标记仍然冲突的记录。 - ACP 驱动的会话的入站消息。 通过 ACP 由程序驱动的会话——无论是否由守护进程生成——会注册并可以发送,但对发送给它的任何内容都回复
refused:暂存是向人提出的问题,而没有人代表它监视暂存列表。暂存消息应在何处为这些会话呈现——其客户端,还是守护进程自身的 API——仍未确定。 - 同一收件箱后的会话对所有对端而言是同一发送者。 托管多个会话的进程以一个
from地址发送,因此接收方的按发送方配额和重复窗口(§6)由该进程的所有会话共享:一个繁忙的兄弟会话可以消耗另一个的配额,刚发送给一个会话的消息体在窗口内不能重复发送给其兄弟。按会话计量需要信任帧中声明的字段,而 §3 的信任模型排除了这一点。