# 跨会话协议

> 本页面描述了一个希望参与跨会话消息传递、但本身不是 Qwen Code 会话的程序所需遵循的契约：例如语音前端、中继守护进程、或监听构建状态的脚本。它描述了一个会话向注册表写入什么、其收件箱从连接中读取什么、以及它如何回复。

- 网址：https://funcoding.ai/agents/qwen-code/users/features/cross-session-protocol/
- 来源：Qwen Code 官方文档原文（中文），Apache-2.0 许可，同步于 2026-10-11
- 官方原文：https://qwenlm.github.io/qwen-code-docs/zh/users/features/cross-session-protocol

---
本页面描述了一个希望参与跨会话消息传递、但本身不是 Qwen Code 会话的程序所需遵循的契约：例如语音前端、中继守护进程、或监听构建状态的脚本。它描述了一个会话向注册表写入什么、其收件箱从连接中读取什么、以及它如何回复。本文所述内容即 schema version 1 和 frame version 1 下代码的实际行为；最后一节说明了哪些内容可能变更以及如何获知变更。

对于 Node 程序，` @qwen-code/sdk/peer` 实现了本页面中接入侧的全部内容——记录、收件箱、认证行、帧和回执——仅依赖 Node，其测试在两个方向上对照 Qwen Code 自身的实现运行。它不对自身的收件箱应用 §6 的任何规则：需要速率限制、暂存或重复窗口的程序需自行实现。可以直接使用它，或继续阅读以自行实现。

所有跨越进程边界的值在到达时均视为不可信，由读取方进行校验。当本页面规定某字段"必须"具有某种结构时，不符合该结构的值会被丢弃，而非以错误拒绝。

## 1. 会话注册表

一个运行中的会话发布一条记录：

```
$QWEN_HOME/sessions/<pid>.json            (目录 0700，文件 0600)
$QWEN_HOME/sessions/<pid>-<8 hex>.json    (一个进程托管多个会话)
```

`$QWEN_HOME` 默认为 `~/.qwen`。文件名以写入者的 PID 为键——可以是裸 PID，也可以是 PID、短横线和注册时生成的八个小写十六进制字符（参见下文"一个进程的多条记录"）。`pid` 字段与文件名 PID 前缀不一致的记录会被忽略——以规范十进制形式比较，因此零填充的文件名不与任何内容匹配。

```json
{
  "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）。在 Linux 上，`pidNs` 是必需的：每个读取方都会将其与自身的进行比较，因此缺少它的记录既不会被列出，也不会被清理。`procStart` 同样是必需的，原因不同：没有它，读取方会退回到简单的 PID 存活检查，无法区分被回收的 PID 和写入该记录的进程。写入同一目录下的临时文件，然后 `rename` 覆盖目标文件；创建文件时权限为 0600；拒绝通过符号链接写入。如果 `<pid>.json` 已经包含你无法证明是由具有相同 PID 的早期进程留下的内容——相同的 `pidNs`、相同的 boot id、不同的 start ticks——则写入 `<pid>-<8 hex>.json` 而非替换它：读取方接受两种文件名，而那里的记录可能属于另一个命名空间或另一台机器上的活跃进程。退出时移除记录。进程已不存在的记录会被下一个列出操作的会话清理，但只有当 `procStart` 能证明该 PID 并非仅仅被复用时才会被清理。` @qwen-code/sdk/peer` 中的 `PeerEndpoint.start()` 会完成以上所有操作，并在 `close()` 时再次移除记录。

**读取。** 任何能读取该目录的进程都能读取所有记录，包括 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，按以下优先级选取第一个可绑定的路径：

1. `$XDG_RUNTIME_DIR/qwen-socks/<pid>.sock`
2. `$TMPDIR/qwen-socks-<16 hex>/<pid>.sock`
3. `/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` 时，第一行必须为：

```json
{ "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. 用户帧

```json
{
  "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` 进行认证：

```json
{
  "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. 接收方如何处理消息

按以下顺序：

1. **准入。** 按发送方：突发 30 条，之后每两秒一条。所有发送方合计：突发 32 条，之后每秒一条——发送方在帧中声明自己的身份，因此轮换名称可以从第一个限制获得新的配额，但不能从第二个限制获得。30 秒内来自另一个会话的相同消息体视为 `duplicate`；会话自身启动的进程和受信任的控制器免于此检查，但仍与其他人一样受速率限制。被丢弃的消息不会被暂存、不会被投递、也不留记录，因此等待突发窗口过后重试的发送方仍然能成功送达。
2. **已裁定 ID。** 闸门已裁定的 `msgId` 会重复其先前的裁定结果。
3. **策略。** `agents.crossSessionInbound` 设置为 `accept`、`hold` 或 `refuse` 时以其为准。未设置时：会话自身启动的进程或受信任的控制器被接受；否则仅当 `fromMode` 声明的审核类别与接收方相同时消息才被接受，其他所有情况（包括 `fromMode` 缺失时）均被暂存。
4. **暂存。** 最多 50 条消息等待。在缓冲区已满时到达的消息会被 `dropped`，原因为 `queue-full`，而非驱逐已暂存的消息。暂存消息在 `agents.crossSessionHeldExpiry`（`1m`、`5m`、`10m`、`never`；默认 `5m`）后过期。用户从 `/peers` 释放或拒绝；模式变更会重新评估积压消息。
5. **排队。** 被接受的消息加入会话的输入队列，该队列最多容纳 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 的信任模型排除了这一点。
