# DeepPilot Bridge Protocol — v2 状态:v2 唯一支持基线。所有帧均为 WebSocket **文本帧**,UTF-8 编码的 JSON。 v2 不接受 v1 Bearer Token、`c2s.hello.auth` 或任何降级握手;升级后设备必须重新配对。 ## 1. 信封(Envelope) ```json { "v": 2, "type": "c2s.session.open", "id": "b3c1d9e0-…", "ts": 1756000000000, "payload": { } } ``` - `v` 必须等于 2;不匹配时服务端回一帧 `s2c.error(E_UNSUPPORTED)` 后以 4500 关闭。 - 命名:客户端请求 `c2s.*`,服务端响应与推送 `s2c.*`。 - 未知 type:服务端回 `E_PROTOCOL` 错误帧,不断连。 ## 2. 连接与鉴权 ### 2.0 传输层诊断标识(可选扩展) 为了让手机端与主机端的失败记录能对应到同一次尝试,双方可交换一个**不参与鉴权**的 短标识 `attemptId`: - HTTP 请求(`GET /phone/health`、`POST /phone/pair`)可携带请求头 `X-DeepPilot-Attempt: `。 - WebSocket 升级请求可在 URL 查询串携带 `at=`,例如 `wss://host/phone?at=a1b2c3d4`。 约束: - `attemptId` 由客户端生成,建议 8 位小写十六进制;服务端只接受 `[A-Za-z0-9_-]{1,16}`,超长或含非法字符时一律忽略,**不得因此拒绝请求**。 - 服务端只把它写入本地诊断记录(终端日志与诊断事件),不校验、不回显、不参与 鉴权,也不改变任何响应语义;缺失时行为与不带该字段完全一致。 - 客户端追加失败(例如 URL 构造异常)必须退回不带该字段的请求,连接不能因为 诊断字段失败。 - 它属于传输层元数据,不是信封字段,不影响 `v`、帧结构与错误帧定义。 - 隐私:`attemptId` 只能是随机标识,不得编码设备 ID、主机地址、配对码或任何凭据。 兼容性:旧客户端不带、旧服务端忽略;双方都实现后,用户只需报告 `attemptId`,即可 在主机诊断记录中检索同一次尝试。 ### 2.1 一次性配对 设置页在用户主动操作后生成 24 字节随机配对码,base64url 编码、5 分钟过期、成功使用 一次后立即失效。二维码格式: ```json { "v": 2, "type": "deeppilot-pairing", "host": "https://example.funnel.ts.net", "code": "", "expiresAt": 1756000300000, "audience": "deeppilot:", "tlsFingerprint": "sha256:" } ``` - `host` 只允许 `https://` 或 `wss://`;App 必须拒绝 `http://` 与 `ws://`。 - `tlsFingerprint` 可选。局域网地址由 Host 自签证书提供 TLS,此时字段必填,值为 证书 SubjectPublicKeyInfo(DER)的 SHA-256,`sha256:` 前缀加 base64url(43 字符)。 Funnel 等由公共 CA 签发的地址不携带该字段。 - App 收到带指纹的配对信息后,对该 Host 的所有 HTTPS/WSS 连接(配对、健康检查、 WebSocket、Widget 刷新)只信任 SPKI 指纹一致的服务端证书,忽略系统信任链与主机名。 没有指纹的 Host 走系统信任链。App 不得对自签证书做“首次信任”提示,缺少指纹时必须 引导用户重新配对。 - Host 的 LAN TLS 密钥持久化在数据目录;证书到期重签不改变指纹,密钥丢失或重建则 所有通过局域网配对的设备都必须重新配对。 - 二维码与可复制的配对文本编码同一段「配对信息」,用户复制一次、粘贴一次即可完成配对: ```text deeppilot://pair?h=&c=&f=&a=&e= ``` `h` 连接地址(必填,与 JSON 的 `host` 相同)、`c` 一次性配对码(必填,即 `code`)、 `f` 证书指纹(局域网必填,公网省略)、`a` 主机标识(即 `audience`)、`e` 过期时间 (毫秒,即 `expiresAt`)。字段名短是因为整段内容既要放进二维码,也要粘贴进输入框; 值按 URL 查询参数规则百分号编码。 - App 必须同时接受这两种形式:JSON(旧版插件二维码)与 `deeppilot://pair` 链接,并对 解析出的字段执行完全相同的校验。链接可以省略 `a` 与 `e`:缺少 `a` 时以 `/phone/pair` 响应中的 `audience` 为准,缺少 `e` 时以 Host 对配对码 5 分钟有效期的判定为准。 - 配对信息包含一次性配对码,属于凭据:插件不得写日志,App 不得持久化未使用的链接。 App 为该主机生成 P-256 Signing 私钥;真机优先使用 Secure Enclave,私钥不得离开设备。 App 向 `POST /phone/pair` 发送: ```json { "v": 2, "code": "", "publicKey": "<65-byte uncompressed X9.63 P-256 key, base64url>", "deviceName": "iPhone 15 Pro", "appVersion": "0.1.0", "scopes": ["sessions.read", "prompt.send", "sessions.manage", "interactions.respond", "notifications.register"] } ``` 成功返回 HTTP 201: ```json { "ok": true, "v": 2, "deviceId": "", "audience": "deeppilot:", "scopes": ["sessions.read", "prompt.send", "sessions.manage", "interactions.respond", "notifications.register"], "tlsFingerprint": "sha256:" } ``` App 扫码时必须确认响应 `audience` 与二维码一致。响应中的 `tlsFingerprint` 可选, LAN Host 回显自己的证书指纹,App 若持有二维码指纹则必须比对一致(TLS 握手已经保证 这一点,回显仅用于显示与二次校验)。配对码无效、过期或已使用返回 401; 频率限制返回 429;设备注册表满返回 409。`GET /phone/health` 只返回最小公开状态, 不承担鉴权。 设备注册表同时保存 App 上报的 `deviceName` 与可选的 `customName`。`customName` 只用于 DSH 主机设置页和运维日志中的显示,不替代 App 上报名称,也不参与 `c2s.auth.prove` 的签名输入;App 后续重连不会覆盖它。将 `customName` 清空后, 设置页恢复显示 App 上报的名称。 ### 2.2 WebSocket 挑战签名 1. 客户端连接 `wss://host/phone`,HTTP Upgrade 不携带凭据。局域网地址同样使用 `wss`: Host 的独立 LAN 监听只提供 TLS,证书自签,App 以配对时获得的 `tlsFingerprint` 固定信任。 协议不再定义明文 `ws://` 传输。 2. 服务端立即发送 `s2c.auth.challenge`: ```json { "v": 2, "type": "s2c.auth.challenge", "payload": { "nonce": "<24-byte base64url random>", "audience": "deeppilot:", "issuedAt": 1756000000000, "expiresAt": 1756000030000 } } ``` 3. 客户端确认 `audience` 与配对记录一致,并在 30 秒内发送 `c2s.auth.prove`: ```json { "v": 2, "type": "c2s.auth.prove", "payload": { "nonce": "", "audience": "deeppilot:", "issuedAt": 1756000000000, "expiresAt": 1756000030000, "deviceId": "", "deviceName": "iPhone 15 Pro", "appVersion": "0.1.0", "resumeCursor": 1042, "clientRole": "widget", "signature": "" } } ``` - `clientRole` 可选,缺省为普通客户端。`"widget"` 声明这是一个短生命周期的 只读概览客户端(如 iOS 主屏幕小组件):完成认证后只允许发送 `c2s.ping`、`c2s.sessions.list`、`c2s.pending.list` 与 `c2s.widget.push.register`,其余请求一律回 `E_FORBIDDEN`;该连接不注册为 广播 sink、不参与 seq 重放(`resumeCursor` 被忽略)、不更新设备 lastSeen,也**不**抑制离线 APNs 告警推送(小组件连接不算"设备在线")。 签名输入不含 `clientRole`,v2 签名规范不变。 签名输入必须是下列 UTF-8 字节。文本字段先做无 padding 的 base64url;时间戳和游标 必须是十进制有限整数,缺失游标写 `-`: ```text deeppilot-auth-v2 device-id: nonce: audience: issued-at: expires-at: device-name: app-version: resume-cursor: ``` `deviceName` 是 App 在当前认证请求中声明的原始名称;主机设置页保存的 `customName` 不在此签名文本中,也不会改变设备身份校验。 4. 服务端仅接受注册、未撤销且签名有效的设备。失败以 **4401** 关闭;超时以 **4402** 关闭;版本不匹配以 **4500** 关闭。welcome 前客户端只允许发送 proof 与 ping。 5. scope 在每次认证时载入内存;scope 变更或撤销会立即断开该设备,重连后重新判定。 scope 与操作映射: | scope | 允许的操作 | |---|---| | `sessions.read` | 会话/工作区/目录读取、会话打开与历史、模型目录读取、`c2s.session.skills` 技能目录,以及 `c2s.session.commands`、`c2s.session.permissions`、`c2s.session.export`、`c2s.plugin.update.check` | | `prompt.send` | `c2s.session.sendPrompt`、`c2s.session.command`、`c2s.session.feedback`、`c2s.session.queue.update` | | `sessions.manage` | 创建/重命名/归档/取消会话,创建工作区,切换模型,`c2s.plugin.update.apply` 安装更新 | | `interactions.respond` | pending 快照、回答 approval 与 question | | `notifications.register` | 注册 APNs token 与通知偏好 | | `schedule.manage` | 定时任务/提醒的列表、历史、创建、修改和删除;同时需要 `sessions.read` 才能读取任务内容 | 缺少所需 scope 时服务端回 `E_FORBIDDEN`,连接保持打开。当前产品默认配对授予 全部 scope,普通设置页不提供逐项权限控制,只提供全局连接开关和逐设备删除。 `c2s.ping` 与 `c2s.resume` 是已认证连接的控制帧,不额外要求业务 scope。 #### 下行(S→C)广播权限 scope 同样约束服务端**推送**给已认证连接的内容。桥在每次广播与重放时按帧类型 执行下行权限判定;未满足 scope 的帧不下发,但计入 seq 游标并加入重放环,使 权限收紧不影响其他设备的投递。 | 下行帧类型 | 需要的 scope | |---|---| | `s2c.models.changed`、`s2c.commands.changed`、`s2c.skills.changed`、`s2c.session.event`、`s2c.sessions.delta`、`s2c.session.tail`、`s2c.history.page`、`s2c.session.queue` | `sessions.read` | | `s2c.pending.approval`、`s2c.pending.question`、`s2c.pending.cleared` | `interactions.respond` | | `s2c.notify`(`category` 为 `approval.required` / `question.asked`) | `interactions.respond` | | `s2c.notify`(`category` 为 `turn.completed` / `session.error`) | `sessions.read`(正文可能包含助手输出) | | `s2c.schedule.changed` | `schedule.manage`(正文为空,客户端需重新拉取列表) | | welcome / ack / error / challenge 及对已通过 scope 检查的 c2s 请求的点对点响应 | 无 | 未知广播类型默认不下发。重放按每帧权限过滤,不要求设备拥有 sessions.read; 有效 cursor 的重放以 s2c.resume.done 结束,过期 cursor 下发 s2c.resync。 这些控制帧不携带业务内容。仅 interactions.respond 的设备可通过 pending 快照恢复。 APNs 必须同时具备 notifications.register 和对应通知类别的内容权限; 单独的注册权限不授予通知正文读取权限。点对点响应由对应请求权限保护。 ### s2c.welcome ```json { "type": "s2c.welcome", "payload": { "protocolVersion": 2, "serverVersion": "0.1.0", "deviceId": "", "scopes": ["sessions.read", "prompt.send"], "capabilities": { "historyPaging": true, "replay": true, "approvals": true, "questions": true, "pendingSnapshot": true, "notifyAllCategories": true, "models": true, "sessionManagement": true, "projectSelection": true, "push": true, "widgetPush": true, "deviceRevoke": true }, "cursor": 1042, "resumed": true } } ``` ## 3. 会话列表 ### c2s.sessions.list → s2c.sessions.snapshot ```json { "type": "s2c.sessions.snapshot", "payload": { "full": true, "sessions": [SessionSummary] } } ``` SessionSummary: 可选字段 `activity` 为当前工具操作的简短描述(最多 160 个 Unicode code points)。按工具 callId 跟踪,工具结束或新一轮开始时清除,不携带思考原文;此字段为协议 v2 向后兼容扩展,旧客户端忽略,新客户端在缺失时回退任务清单或状态提示。 ```json { "id": "session-…", "title": "修复登录 bug", "status": "running", "lastActivityTs": 1756000000000, "todos": { "done": 3, "total": 7 }, "todoItems": [ { "content": "定位登录跳转问题", "status": "completed" }, { "content": "修复重定向逻辑", "status": "in_progress" }, { "content": "补充回归测试", "status": "pending" } ], "pendingApproval": false, "pendingQuestion": true, "stats": { "turns": 12, "steps": 34, "llmMs": 680000, "toolMs": 210000, "ttftMs": 15980, "ttftSteps": 34, "decodeMs": 262000, "decodeTokens": 29700, "inputTokens": 169000, "outputTokens": 29700, "cacheReadTokens": 1131000, "cacheWriteTokens": 0 }, "workspaceLabel": "deeppilot-demo", "workspaceId": "workspace-…", "workspacePath": "/Users/sea/Development/deeppilot-demo" } ``` `status` 取值:running | idle | error | unknown;当前 v2 Bridge 的会话镜像稳定产生 running/idle,error/unknown 为 Host 状态扩展保留;`todos` 无则为 null。 `todoItems` 为完整清单条目(`content` 非空字符串;`status` 取值 pending | in_progress | completed),供会话详情页渲染任务进度;无任务时为 null 或缺省。该字段为可选字段, 客户端必须容忍缺失。 `stats` 为会话累计用量统计,由 Bridge 镜像 Host 的 `sessionStats` 与 `tokenUsage` 两个 projection 合并而来(分别来自 dsh-session-stats / dsh-token-meter)。所有字段为 非负整数(0 表示尚未记录),随 `s2c.sessions.delta` 实时更新。客户端展示派生值: 首 token 平均 = `ttftMs / ttftSteps`;输出速度 = `decodeTokens / decodeMs × 1000` (tok/s);缓存命中率 = `cacheReadTokens / (inputTokens + cacheReadTokens + cacheWriteTokens)`;输入总量 = `inputTokens + cacheReadTokens + cacheWriteTokens`。 该字段为可选字段:未安装统计 projection 的旧版 Host 或尚未产生任何统计的会话 会缺省或为 null,客户端必须容忍并回退。 列表变更推送(握手后自动开始,无需订阅): ```json { "type": "s2c.sessions.delta", "payload": { "upserted": [SessionSummary], "removedIds": [] } } ``` 会话与项目均按 `lastActivityTs` 从新到旧展示;项目使用项目内最新会话时间排序。归档后 Bridge 必须把该会话放入 `removedIds`,不得继续出现在普通列表。 ## 4. 会话明细 ### c2s.session.open(payload: sessionId, tailCount? 默认 100) 服务端先回一次性 `s2c.session.tail`,随后该会话实时事件以 `s2c.session.event` 推送。历史快照请求尚未完成时产生的实时事件必须暂存,并在 tail 之后按原顺序发送,客户端不得先看到 event 再被较旧的 tail 回滚。多设备各自 open 各自收,互不影响。 ### c2s.session.close(payload: sessionId) 客户端离开会话详情时发送;服务端取消该连接对该会话的实时订阅与“正在查看”标记, 不关闭 WebSocket,也不终止会话 turn。该帧为幂等通知,不产生响应。 ### s2c.session.tail 与 c2s.session.history ```json { "type": "s2c.session.tail", "payload": { "sessionId": "…", "messages": [Message], "oldestSeq": 12, "hasMore": true } } { "type": "c2s.session.history", "id": "history-1", "payload": { "sessionId": "…", "beforeSeq": 12, "limit": 100 } } { "type": "s2c.history.page", "id": "history-1", "payload": { "sessionId": "…", "messages": [Message], "hasMore": false } } ``` `c2s.session.history` 必须携带 `id`;对应的 `s2c.history.page` 回显同一 `id`,使客户端能够在页面真正应用后结束加载状态并恢复可见消息锚点。同一会话在前一个 history 请求完成前不得并发请求下一页。 `Message.seq` 是会话内唯一且稳定的消息行身份。同一个 tail/page 内不得重复 `seq`;`s2c.history.page.messages` 必须全部满足 `seq < beforeSeq`。Host 返回重复或包含边界的事件时,Bridge 必须先按 `seq` 去重并过滤边界,避免客户端上滑时反复追加同一页。 Message 投影: ```json { "seq": 41, "role": "assistant", "text": "……markdown 原文……", "thinking": "……推理(thinking)原文……", "streaming": false, "tool": { "name": "bash", "state": "ok", "summary": "pnpm test 通过" }, "attachments": [ { "kind": "image", "name": "photo.jpg", "mediaType": "image/jpeg", "attachmentId": "att-…", "width": 2048, "height": 1536 }, { "kind": "document", "name": "notes.md", "mediaType": "text/markdown" } ], "context": { "label": "runtime-context", "form": "snapshot" }, "ts": 1756000000000, "truncated": false } ``` - `role`:user | assistant | tool | system | error;`tool` 仅 tool 行存在,state 为 running | ok | error。 - `role: system` 表示宿主注入的模型侧上下文(运行时快照、后台任务通知、工作区指令、技能内容等), 不是真人发言。DSH Host 会把这类 `agent.inject()` 内容与真人 prompt 同样记为 user-role 消息, 但其消息 `source.kind` 不是 `'user'`;Bridge 参照 Host 轨迹视图的分类规则,把这类行投影为 `system`,客户端必须与用户气泡区分展示。 - `context`:仅 system 行可选携带的来源信息。`label` 为生产者名(插件名 / 技能名 / 指令路径等), `form` 为语义类别(instructions | catalog | snapshot | notice | relay | recall)。两者皆可缺省, 出现未知取值时客户端按不透明文本处理,不得丢弃该行。 - `thinking`:assistant 行可选,携带模型的推理(reasoning)文本;正文与推理均为空的 assistant 行不下发。 - `streaming: true` 只出现在推送中间态,final/tail/history 中恒为 false。 - `truncated: true` 表示该条 Message 的 UTF-8 JSON 序列化投影原本超过 256KB, Bridge 已缩短正文/推理/摘要或附件元数据,使最终单条投影不超过 256KB。 - 一次 `s2c.session.tail` / `s2c.history.page` 的 `messages` JSON 数组不超过 900KB;超出时保留最接近请求边界的较新后缀,并令 `hasMore: true`,客户端继续分页即可完整取回,禁止发送一个超大整帧后让客户端静默丢弃。 - `hasMore` 的语义是「请求边界之前**还有能产出消息**的历史」。窗口起点之前只剩投影为空的事件(如 `system/message`、`turn/*`、流式 `assistant/chunk`)时,Bridge 置 `hasMore: false`——客户端据此停止翻页,不会收到空页。 - `attachments`:仅 user 行携带的附件清单。图片的 `kind=image`,`attachmentId` 是宿主附件服务的持久引用, 供 c2s.session.attachment 读回原图;`width`/`height` 为像素尺寸(可选,供客户端预留布局)。 文本文档的 `kind=document`,由 Bridge 以带边界的模型文本提交;当前 Host 没有通用二进制附件服务, 因此文档不提供 attachmentId/read-back。`truncated=true` 表示客户端只提交了可读文本前缀。 attachmentId/宽高为可选字段,客户端必须容忍缺失。 ### c2s.session.skills → s2c.session.skills 协议 v2 的可选兼容扩展,能力位 `skills` 缺省视为 false。请求需要 `sessions.read`;小组件连接不允许调用。响应沿用请求 `id`,不进入离线重放。 ```json { "type": "c2s.session.skills", "id": "catalog-1", "payload": { "sessionId": "session-1" } } { "type": "s2c.session.skills", "id": "catalog-1", "payload": { "sessionId": "session-1", "skills": [ { "name": "review", "description": "检查代码", "modelInvocable": true } ] } } ``` 1. 插件调用当前 Host 的 `sessionSkillCatalog.list({sessionId}, signal)`,由上游按会话当前工作目录与预设筛选用户可调用技能;不扫描插件固定目录,不读取技能正文。 2. 响应只投影 `name`、`description`、`modelInvocable`;指令文件路径和正文不传送。`modelInvocable=false` 仍允许用户显式调用,客户端显示「仅限用户调用」。 3. 点击候选只将当前光标处的斜杠词补全为 `/name `,保留前后草稿与键盘,用户继续编辑后通过既有 `c2s.session.sendPrompt` 发送。此能力不增加命令执行通道。 4. 主机缺少该目录服务时能力为 false,请求回 `E_UNSUPPORTED`;目录读取失败回既有错误帧,不伪装成空目录。 ### s2c.skills.changed 技能注册表失效时广播 `{}`;会话工作目录或预设变化时广播 `{sessionId}`。此帧只通知失效,不携带技能内容,需要 `sessions.read`,进入既有重放序列。客户端丢弃对应目录及在途结果;候选面板打开时重新读取,关闭时等下一次触发。重连必须重新读取,不复用旧连接结果。 ### 会话模型目录与切换 模型列表必须由 Host 的 `session.models` 动态提供,DeepPilot Bridge 和 App 不得内置供应商、模型名称或思考强度选项。`welcome.capabilities.models=false` 时客户端保留入口并显示明确的能力不可用状态。 ```json { "type": "c2s.session.models", "id": "m-1", "payload": { "sessionId": "session-…" } } { "type": "s2c.session.models", "id": "m-1", "payload": { "sessionId": "session-…", "current": { "provider": "deepseek", "model": "deepseek-chat", "reasoningEffort": "high" }, "routable": true, "groups": [{ "id": "deepseek", "name": "DeepSeek", "models": [{ "id": "deepseek-chat", "name": "DeepSeek Chat", "description": "…", "reasoning": { "efforts": [{ "id": "high", "name": "高", "description": "…" }], "defaultEffort": "high" } }] }], "failures": [] } } ``` 切换提交完整路由;`reasoningEffort` 可选,并使用模型目录中对应模型声明的 effort id。服务端只在 Host 校验并应用成功后返回 selected。 ```json { "type": "c2s.session.selectModel", "id": "m-2", "payload": { "sessionId": "session-…", "provider": "deepseek", "model": "deepseek-reasoner", "reasoningEffort": "high" } } { "type": "s2c.session.modelSelected", "id": "m-2", "payload": { "sessionId": "session-…", "selected": { "provider": "deepseek", "model": "deepseek-reasoner", "reasoningEffort": "high" } } } ``` `groups` 是建议目录,是否能启动下一轮以 `routable` 为准;单个 Provider 目录失败进入 `failures`,不得使其他 Provider 消失。 ### s2c.session.event(实时推送) `kind` 取值:message.start / message.delta / thinking.delta / message.final / tool.start / tool.end / turn.start / turn.end / projection / error。 当前 v2 Bridge 主动发射 message.delta / thinking.delta / message.final / tool.start / tool.end / turn.start / turn.end;其余 kind 为兼容 Host 后续事件投影而保留,客户端必须忽略未知或暂未使用的 kind。 ```json { "type": "s2c.session.event", "payload": { "sessionId": "…", "kind": "message.delta", "seq": 42, "data": { "text": "增量片段" } } } ``` - message.delta/message.final 共用同一会话内 seq;data.text 为增量/全文。极端超大增量同样会被限制在 256KB 内,并在 data 附 `truncated:true`。 - thinking.delta 的 data.text 为推理增量,seq 与同会话其他事件一致;客户端应把连续增量折叠进同一条"思考"行(role=assistant、thinking 累积、streaming=true),final 到达后由带 `thinking` 字段的正式行替换。 - tool.start 的 `data.tool` 为 `{name, state:"running", summary}`;Host 事件携带调用 id 时额外附带 `tool.callId`。tool.end 的 data 附带 ok 布尔与该结果事件自身的 seq,并在 Host 事件携带调用 id 时附带 `callId`——`seq` 标识的是 result 事件本身,客户端必须用 `callId`(缺失时按"最旧的未完成工具行"兜底)把结果合并回对应的 tool.start 行,不得按 seq 匹配。 `tool.end.data` 可选附带 `summary` 和 `attachments`(与 MessageProjection 相同的有界摘要与持久附件元数据,不含图片字节);客户端合并到对应工具行,未见起始行时可按结果自身 seq 创建独立结果行。这是 v2 向后兼容扩展,旧客户端忽略新增字段。 - turn.end 的 data 附带 ok 布尔;projection 的 data 为 key/value(如 todos)。 ## 4b. 项目选择与新建会话 `welcome.capabilities.projectSelection=true` 表示 Bridge 同时具备 Host `workspace.list/create` 能力。新建会话界面先列出现有项目: ```json { "type": "c2s.workspaces.list", "id": "w-1", "payload": {} } { "type": "s2c.workspaces.snapshot", "id": "w-1", "payload": { "workspaces": [{ "id": "workspace-…", "title": "deeppilot-demo", "path": "/Users/sea/Development/deeppilot-demo", "sessionIds": ["session-…"] }] } } ``` 添加项目时优先使用 Host 的远程目录浏览能力。`path` 缺省时从 Host 用户主目录开始;客户端只能回传 Host 给出的绝对路径,不自行拼接路径: ```json { "type": "c2s.directory.list", "id": "d-1", "payload": { "path": "/Users/sea/Development" } } { "type": "s2c.directory.listing", "id": "d-1", "payload": { "path": "/Users/sea/Development", "home": "/Users/sea", "crumbs": [], "entries": [], "truncated": false } } ``` DeepPilot 的标准 bundle 固定组合 Host 官方 `directory-picker-browse` 双面包,使远程 iPhone 能调用 `directory.list`。Host 不提供该能力时请求返回 `E_UNSUPPORTED`;客户端可在用户明确点击后请求电脑上的系统选择器。取消时 `path=null`: ```json { "type": "c2s.directory.pick", "id": "d-2", "payload": {} } { "type": "s2c.directory.picked", "id": "d-2", "payload": { "path": "/Users/sea/Development/new-project" } } ``` 选中已有文件夹后,以 Host `workspace.create` 采用为项目;重复采用同一路径是幂等成功(`created=false`): ```json { "type": "c2s.workspace.create", "id": "w-2", "payload": { "path": "/Users/sea/Development/new-project" } } { "type": "s2c.workspace.created", "id": "w-2", "payload": { "workspace": { "id": "workspace-…", "title": "new-project", "path": "/Users/sea/Development/new-project", "sessionIds": [] }, "created": true } } ``` ### c2s.session.create(payload: workspaceId? 或 cwd?) 客户端正常流程必须先选择项目并发送 `workspaceId`。`cwd` 用于显式路径创建;两者互斥。服务端通过核心 API 创建空白会话并返回 id,随后客户端可 session.open 订阅。 ```json { "type": "s2c.ack", "payload": { "sessionId": "session-…" } } ``` `projectSelection=false` 时,客户端保留入口并明确提示能力不可用,不得静默创建到 Host 默认目录。 ## 4c. 会话管理 会话重命名真实调用 Host `session.rename`,成功后返回 Host 接受的规范化名称: ```json { "type": "c2s.session.rename", "id": "r-1", "payload": { "sessionId": "session-…", "title": "新的名称" } } { "type": "s2c.session.renamed", "id": "r-1", "payload": { "sessionId": "session-…", "title": "新的名称" } } ``` 归档真实调用 Host `workspace.archiveSession`。归档仅从所有普通分组界面隐藏会话,保留会话日志与原项目位置;成功响应后同一会话还会通过 sessions delta 的 `removedIds` 从列表镜像移除。 ```json { "type": "c2s.session.archive", "id": "a-1", "payload": { "sessionId": "session-…" } } { "type": "s2c.session.archived", "id": "a-1", "payload": { "sessionId": "session-…" } } ``` Host 不具备这两个 RPC 时 `welcome.capabilities.sessionManagement=false`,请求返回 `E_UNSUPPORTED`,客户端不得伪造本地成功状态。 ### c2s.sessions.archived → s2c.sessions.archived.snapshot 按需拉取已归档会话,供归档列表界面浏览。返回行与 `s2c.sessions.snapshot` 的 SessionSummary 同构,并额外携带 `archived: true`,按 `lastActivityTs` 降序排列。 ```json { "type": "c2s.sessions.archived", "id": "ar-1", "payload": {} } { "type": "s2c.sessions.archived.snapshot", "id": "ar-1", "payload": { "sessions": [SessionSummary] } } ``` 归档行**不会**出现在 `c2s.sessions.list` 的响应、`s2c.sessions.delta` 的 `upserted`/`removedIds` 或任何推送中。因此不支持本 RPC 的旧客户端行为完全不变;新客户端必须显式请求才能看到归档会话。 ### c2s.session.unarchive → s2c.session.unarchived 恢复归档会话,真实调用 Host `workspace.unarchiveSession`。恢复后该会话重新出现在普通列表:Bridge 会随即重发 `s2c.sessions.delta`,其中该会话位于 `upserted`。归档集合以 Host 返回的完整 `archivedSessionIds` 为准。 ```json { "type": "c2s.session.unarchive", "id": "u-1", "payload": { "sessionId": "session-…" } } { "type": "s2c.session.unarchived", "id": "u-1", "payload": { "sessionId": "session-…" } } ``` Host 不具备该能力时 `welcome.capabilities.sessionRestore=false`,请求返回 `E_UNSUPPORTED`。`c2s.sessions.archived` 属默认的 `sessions.read` 权限域;`c2s.session.unarchive` 属 `sessions.manage`。 ## 5. 写链路 ### c2s.session.sendPrompt(payload: sessionId, text, images?, documents?) 服务端受理后回 `s2c.ack`(payload 附 `userSeq`),随后该输入以正常消息事件流入会话流。`userSeq` 是 Bridge 生成的受理回执标记,不属于会话事件 seq,客户端不得拿它与 `session.event.seq` 对账。会话正忙回 `E_BUSY`。`text`、`images` 与 `documents` 至少一项非空。 `images` 最多 4 项,每项为 `{mediaType,data,name?}`。`mediaType` 仅允许 `image/png`、`image/jpeg`、`image/webp`、`image/gif`,`data` 为无 data-URL 前缀的标准 base64。Bridge 做数量、类型和单项体积初筛,DSH Host 再按当前模型与附件服务限制完成最终校验和持久化。 `documents` 最多 4 项,且与 images 合计不超过 4 项。每项为 `{mediaType,name,text,truncated?}`:App 在本机提取 UTF-8 文本(包括 PDF 的文本层),单项不超过 256K 字符;Bridge 以明确文件名和边界的 text block 交给 Host。二进制 Office/压缩包等无法安全提取文本的格式必须由客户端拒绝,不得假装上传成功。 ```json { "type": "c2s.session.sendPrompt", "payload": { "sessionId": "session-…", "text": "分析这张图", "images": [{"mediaType":"image/jpeg","data":"/9j/…","name":"photo.jpg"}], "documents": [{"mediaType":"text/markdown","name":"notes.md","text":"# Notes"}] } } ``` 可选字段 `mode` 取 `"queue"`(默认)或 `"steer"`,仅在 `welcome.capabilities.promptQueue=true` 时有效,语义见「运行中排队与转向扩展」。能力位缺失时,`mode:"queue"` 与省略等价,仍然允许;`mode:"steer"` 返回 `E_UNSUPPORTED`。 ### c2s.session.attachment(payload: sessionId, attachmentId) 按 id 读回一张会话引用过的持久化图片(宿主校验该会话日志确实包含此 id 后才返回)。 RPC 响应 result 为 `{ "mediaType": "image/jpeg", "data": "" }`;宿主不可用或 id 无效回 `E_NOT_FOUND`。 客户端应做磁盘缓存,避免重复拉取。 ```json { "type": "c2s.session.attachment", "id": "r-9", "payload": { "sessionId": "session-…", "attachmentId": "att-…" } } { "type": "s2c.ack", "id": "r-9", "payload": { "mediaType": "image/jpeg", "data": "/9j/…" } } ``` ### c2s.session.cancel(payload: sessionId) 中止该会话当前正在执行的 turn(真实调用 Host `session.cancel({sessionId})`,保留队列 FIFO 续跑)。服务端受理后回 `s2c.ack`(payload 附 sessionId),随后 turn 以正常 `turn.end`(reason=interrupted)事件流入会话流。会话空闲时同样回 ack(幂等);会话不存在回 `E_NOT_FOUND`。 ```json { "type": "c2s.session.cancel", "id": "c-1", "payload": { "sessionId": "session-…" } } { "type": "s2c.ack", "id": "c-1", "payload": { "sessionId": "session-…" } } ``` ### 审批 ```json { "type": "s2c.pending.approval", "payload": { "requestId": "apr-1", "sessionId": "…", "toolName": "bash", "summary": "pnpm install", "riskLevel": "write" } } { "type": "c2s.approval.respond", "payload": { "requestId": "apr-1", "decision": "allow", "reason": "" } } { "type": "s2c.pending.cleared", "payload": { "requestId": "apr-1" } } ``` - decision:allow | deny;reason 可选,deny 时可附说明。 - riskLevel:read | write | destructive。 - toolArguments:可选字符串,原始完整工具参数(通常为 JSON),用于展示真实执行内容;summary 为审批原因,toolName 仅为工具名。此字段是协议 v2 的向后兼容扩展:旧客户端忽略,新客户端在缺失时显示执行内容不可用,不将工具名视为命令。桥接层按同一 session 的 callId 和工具名匹配 tool/call,读取最近 200 条消息范围的历史;未匹配则省略,不猜测、不截断。参数获取完成后可用同一 requestId 更新 pending.approval,pending.snapshot 保留此字段。 - 挂起审批同时体现在 SessionSummary.pendingApproval。 ### 提问 ```json { "type": "s2c.pending.question", "payload": { "requestId": "q-1", "sessionId": "…", "questions": [ { "id": "mode", "question": "选择方案", "multiSelect": false, "options": [ { "label": "方案 A(推荐)", "description": "改动最小" } ] } ] } } { "type": "c2s.question.respond", "payload": { "requestId": "q-1", "answers": [ { "id": "mode", "selected": ["方案 A"] } ] } } ``` - 无 options 的题为自由文本:selected 留空数组、填 custom。multiSelect 为 true 时 selected 可多项。 - `custom` 仅在用户确实输入了非空白自由文本时携带;禁止发送 `"custom": ""`。 主机侧严格校验答案批次(逐题 id、选项 label 集合),出现空 `custom`、重复 label, 或单选题同时携带 selected 与 custom,都会被整体拒绝(`E_PROTOCOL`,answer rejected)。 - Bridge 在转发前会按上述规则归一化 answers(剔除空白 custom 等)。 ### 待处理快照 APNs 只承载通知投影,不承载回答所需的 requestId 和完整问题选项。客户端在握手、 通知点击或 `s2c.resync` 后应主动请求当前待处理快照;这条路径不依赖有限长度的重放环。 ```json { "type": "c2s.pending.list", "id": "pending-1", "payload": {} } { "type": "s2c.pending.snapshot", "id": "pending-1", "payload": { "approvals": [ { "requestId": "apr-1", "sessionId": "…", "toolName": "bash", "summary": "pnpm install", "riskLevel": "write" } ], "questions": [ { "requestId": "q-1", "sessionId": "…", "questions": [ { "id": "mode", "question": "选择方案", "multiSelect": false, "options": [] } ] } ] } } ``` - `welcome.capabilities.pendingSnapshot=true` 表示服务端支持此请求;false 或缺失时客户端依赖重放并降级展示。 - 快照是全量替换语义;空数组表示当前没有对应的待处理请求。 ### 与 DSH 官方 Web 回答者的共存 在 DSH `0.2.0-rc.1` 中,Host 侧的 `approval/request` 与 `user-questions/request` 只由官方 API Remotes 接入一次;API Gateway 为每个请求 保存统一 pending 状态,并把相同请求并行投递给官方 Web Client 与 DeepPilot 驻留 Remote Client。任一 Client 先回答后,由 Gateway 统一结算并取消其他 Client 上的同一请求。因此 DeepPilot 不直接注册或抢占 Host waterfall,也不替换官方 Web composer。 存在至少一台已配对且未吊销的设备时,DeepPilot Client 保持请求待答并向手机 发布 `s2c.pending.*`;设备离线、APNs 不可用或通知被静默都不影响稍后通过 `c2s.pending.list` 主动拉取。没有可用配对设备时,DeepPilot Client 对自己的 delivery 调用 `next()`;这不会撤销 Gateway 已经并行交给官方 Web 的 delivery。 Web 或另一 Client 先回答时,Gateway cancellation 会让插件发布对应的 `s2c.pending.*.resolved`,手机上的卡片随即失效。手机先回答时, `c2s.*.respond` 的结果沿官方 Remote Events result 通道返回 Gateway,由 Gateway 负责 first-answer-wins 仲裁。 ## 6. 通知 ```json { "type": "s2c.notify", "payload": { "notificationId": "n-1", "category": "turn.completed", "sessionId": "…", "title": "任务完成", "body": "测试全部通过", "ts": 1756000000000 } } ``` - category:turn.completed | approval.required | question.asked | session.error。 - `welcome.capabilities.notifyAllCategories=true` 表示 Bridge 会为上述四类事件统一发送 `s2c.notify`。此时客户端只能从 `s2c.notify` 触发横幅、声音等通知展示,不得再从 `s2c.session.event(turn.end)`、`s2c.pending.approval` 或 `s2c.pending.question` 重复展示。 - `s2c.session.event` 和 `s2c.pending.*` 仍是会话内容、待处理状态及回答操作的权威数据源; `s2c.notify` 只是面向用户的展示投影。Bridge 必须先记录权威事件,再记录对应 notify, 以保证按 seq 重放时状态先于通知到达。 - `notifyAllCategories` 为 false 或缺失时,客户端可从 turn.end / pending.* 做回退; 一旦能力为 true,就必须关闭这些回退分支。 - 触发规则(F-9):对未打开该会话的在线设备,在 turn 结束、出现 pending.approval / pending.question 或会话 error 时发送;离线设备走同事实的 APNs 投影。 - `notificationId` 标识同一个逻辑通知,在实时 WS、WS 重放和 APNs 投影之间保持稳定; 客户端应按该字段幂等去重。notify 计入 seq 游标参与重放。 - `title` / `body` 是可直接展示的回退文本;客户端可按已知 category 使用本地化标题, 但不得改写动态 body 或依赖 title 文案判断类别。 - `body` 是单行纯文本:Bridge 出站前去掉 Host 原文的 Markdown 标记(链接只留文字、 丢弃代码块与图片、解码残留的 URL 编码),`turn.completed` / `session.error` 取助手 最后回复的最后一个非空段落(为空时退回会话标题),审批与提问把原文合成一行,再截断。 - 可选字段 `toolName`(≤ 80 字符)与 `detail`(≤ 600 字符,Host 给出的审批原因 / 摘要) 只出现在 `approval.required` 的 `s2c.notify` 与 APNs / Relay 投影中,供长按展开页显示; 不改变 `body` 的 120 / 200 字符上限,也不用于判断类别。其他类别忽略这两个字段。 插件 `push.contentMode` 为 `generic` 时两个字段不出站。APNs 提醒推送同时携带 `aps.mutable-content: 1`,让 Notification Service Extension 可按类别附上缩略图; `session.error` 与 `approval.required`、`question.asked` 一样使用 `interruption-level: time-sensitive`。 ### 离线推送(APNs) `welcome.capabilities.push=true` 表示 Bridge 已配置 APNs。App 在获得系统远程通知 token 后发送: ```json { "type": "c2s.push.register", "id": "pu-1", "payload": { "deviceToken": "<64 位 hex 设备 token>", "environment": "development", "categories": { "turn.completed": true, "approval.required": true }, "enrollKey": "<可选:分发版 App 内置的注册密钥>" } } { "type": "s2c.ack", "id": "pu-1", "payload": { "enabled": true } } ``` - ack 的 `enabled`:注册处理完成后的实际就绪状态。自动注册 场景下 Bridge 可能在本次注册中才切换为就绪,客户端据此立即更新本地能力 标记,无需等待下一次握手;客户端须容忍 `enabled` 缺省。 - `deviceToken`:hex 字符串,32–512 个字符;服务端只接受 `[0-9a-f]`。 - `environment`:development | production。设备按自身构建自动上报 (调试=sandbox,TestFlight/App Store=production),Bridge 按设备逐一路由。 - `categories` 可选:设备端按类别的开关镜像;缺省视为全开。Bridge 对离线设备 推送时必须尊重该开关。 - 投递前提:目标设备必须持有 `notifications.register` scope——即使持有 APNs token,scope 被撤销的设备也不接收离线推送;同时必须满足上述通知类别的内容权限。 - `enrollKey` 可选:分发者内置到 App 的共享注册密钥。Bridge 在 未配置任何推送 provider 时收到它,会自动切换为 relay 模式并向中继执行 自动注册(零配置接入);已显式配置 provider 的 Bridge 忽略该字段。 - 每次握手成功后 App 应重新发送(token 与开关都可能变化);重复注册幂等。 - 能力为 false 时请求回 `E_UNSUPPORTED`。 **推送触发规则**:与 notify 帧相同的事件(turn 完成/异常、待审批、提问、会话出错), 对「已持有 token 且当前无活跃 WebSocket 连接」的设备经 APNs 下发;在线设备的 通知仍走 WS 帧 + 本地通知路径,两条通道互斥以避免重复横幅。推送不计入 seq 游标、不参与重放(重连后的离线事件由 resume 重放覆盖)。 ### 小组件刷新推送(WidgetKit) `welcome.capabilities.widgetPush=true` 表示 Bridge 支持小组件内容失效推送。 小组件扩展(iOS 26+)经系统拿到 WidgetKit 专属推送 token 后,用 `clientRole:"widget"` 的短连接发送: ```json { "type": "c2s.widget.push.register", "id": "wp-1", "payload": { "deviceToken": "<64 位 hex WidgetKit token>", "environment": "development", "enrollKey": "<可选,同 c2s.push.register>" } } { "type": "s2c.ack", "id": "wp-1", "payload": { "enabled": true } } ``` - 该 token 与告警推送 token **相互独立**:两者类型不同、生命周期不同,分别 存储,吊销设备时一并清除;重新配对/重复注册幂等,token 轮换会覆盖旧值。 - 注册除 `notifications.register` 外还要求设备持有 `sessions.read` 与 `interactions.respond` scope(小组件概览同时包含会话与待处理计数)。 - **载荷完全无内容**:APNs 帧体固定为 `{ "aps": { "content-changed": true } }`, 头为 `apns-push-type: widgets`、topic 追加 `.push-type.widgets` 后缀、 `apns-priority: 5`、`apns-collapse-id: widget-overview`。它不含会话、 标题或任何业务字段,只表达"概览已变化,请重新拉取"。 - **触发与节流**:Bridge 在概览投影(会话摘要 + 待处理集合)的指纹实际变化 时触发,服务端按每 Host 30 秒合并/节流(保留尾沿状态,不饿死持续更新)。 在线/离线都发送;小组件收到推送后自行以 `clientRole:"widget"` 短连接 拉取 `sessions.list` + `pending.list` 并断开。 - WidgetKit token 由系统管理且预算受限,Bridge 侧超 7 天未刷新的 token 视为 过期不再发送;APNs 返回终态失效(Unregistered/ExpiredToken)时立即清除, BadDeviceToken 可能只是环境不匹配,保留以便诊断。 - 能力为 false/缺失时客户端不得发送该请求(服务端回 `E_UNSUPPORTED` 或 `E_PROTOCOL`)。Relay 分发模式同样透传该推送,中继只做转发与限流。 ### 设备自撤销(可选 v2 扩展) `welcome.capabilities.deviceRevoke = true` 表示 Bridge 接受设备自己发起的解绑。App 删除某个已配对的 DSH 实例(即删除本地配置与 Keychain 私钥)前必须先发该帧: 只删本地凭据不会通知主机,主机侧该设备记录仍带着 apns/widgetApns/liveActivity token,而离线推送的目标筛选条件是「持有 token 且当前无活跃 WebSocket」,于是这台 已解绑的手机变成永久推送目标。该帧是这条链路的唯一止血入口: ```json { "type": "c2s.device.revoke", "id": "rv-1", "payload": {} } { "type": "s2c.ack", "id": "rv-1", "payload": { "revoked": true } } ``` - 前置条件:连接必须已通过 `c2s.auth.prove` 认证;未认证时与其它业务帧一致地失败 (`E_PROTOCOL` / `E_AUTH`)。该帧不要求任何 scope:设备生命周期操作不能因为 scope 被收窄而无法解绑,否则它只会永远留在离线推送目标集合里。 - `payload.deviceId` 可选。省略时操作本连接已认证的设备;提供时必须等于该 deviceId, 否则回 `E_FORBIDDEN` 且不执行任何撤销。 - `clientRole:"widget"` 的短连接一律回 `E_FORBIDDEN`:小组件不是设备的所有者, 不得解绑设备。 - 效果:Bridge 吊销该设备(删除 `apns` / `widgetApns` / `liveActivity` 并写入 `revokedAt` 墓碑),并关闭该 deviceId 的其它活跃连接。从此该设备不再是离线推送 目标,也无法再用同一把钥匙完成握手。 - 响应:先回 `s2c.ack { revoked: true }`,随后**以 4401 + reason `device revoked` 关闭本连接**。ack 必须早于关闭,客户端据此确认解绑已完成。 - 幂等:记录已被撤销时同样回 `{revoked:true}` 并关闭,不返回错误。 - 客户端在删除本地凭据前发送该帧;只有收到 `revoked:true`(或断线重试后仍为该结果) 才继续删除本地实例,避免删掉凭据却留下推送目标。 ### 主机设置页设备自定义名称(DSH Typert 扩展) 设备自定义名称只通过 DSH 主机设置页的 Typert Remote 暴露,不新增 iPhone WebSocket 帧,也不改变设备认证协议: ```text deeppilot/setDeviceName(deviceId, customName) -> customName | null ``` - `deviceId` 必须是已配对且未撤销的 43 字符 base64url 标识。 - `customName` 必须是 1–64 个字符,去除首尾空白后不能为空,禁止 C0/C1 控制字符; 传入 `null` 表示清除自定义名称并恢复 App 上报的 `deviceName`。 - 成功结果回显规范化后的 `customName`;清除时回 `null`。未找到或已撤销设备失败, 不得伪造成功。 - 主机必须将名称原子写入设备注册表后再返回成功;App 后续重连只更新原始 `deviceName`,不能覆盖 `customName`。 - `deeppilot/report` 的设备项可携带 `customName`,并以 `deviceName` 作为有效显示名 (有自定义名称时优先使用自定义名称)。旧 Host 不提供该字段时,客户端继续使用 `deviceName`。 ## 7. 断线重放 - 每个 s2c 推送帧信封额外携带数值字段 seq(服务端本次启动以来单调递增),覆盖 sessions.delta / session.event / notify / pending.* 。请求响应帧不占 seq。 - 重连时 `c2s.auth.prove` 带 `resumeCursor`:命中缓冲则按序补发原帧,补发完追加一帧 `s2c.resume.done`。 - 游标过旧(超出环形缓冲)则发 `s2c.resync`(payload.reason 为 gap),客户端应重新拉 sessions.list 并重开关注的会话。 ## 8. 心跳与生命周期 - 客户端每 25 秒发 `c2s.ping`(payload 空);服务端回 `s2c.pong`(payload.serverTime)。 - 服务端对死连接:60 秒无任何入站帧即以 **1001** 关闭。 - 服务端优雅停机:先向所有连接发 `s2c.error`(E_INTERNAL,server stopping), 再以 **1001** 关闭;客户端不应把这两类 1001 当成异常网络故障。 - 单个客户端持续来不及读取、服务端待发送缓冲超过 4MB 时,以 **1013** 关闭; 客户端可按临时过载执行退避重连。 - 设备自撤销(§6)在 ack 之后以 **4401** + reason `device revoked` 关闭该连接; 它与鉴权失败的 4401 同码不同因,客户端应据 reason 与上一帧 ack 区分,且不得因此 重连。 ## 9. 错误帧 ```json { "type": "s2c.error", "id": "", "payload": { "code": "E_NOT_FOUND", "message": "session not found" } } ``` | 码 | 含义 | |---|---| | E_AUTH | 设备未注册、已撤销或签名证明无效 | | E_FORBIDDEN | 设备 scope 不允许该操作 | | E_PROTOCOL | 未知类型或非法 payload | | E_NOT_FOUND | 会话或请求不存在 | | E_BUSY | 会话正在处理上一条输入 | | E_UNSUPPORTED | 协议版本或能力不支持 | | E_INTERNAL | 服务端内部错误 | | E_STEER_UNAVAILABLE | 排队项已无法转向:当前这一轮已结束,或该项不在排队区。仅在 `promptQueue` 能力下的 `c2s.session.queue.update` 中出现 | `message` 是人类可读的诊断文本,客户端可原样展示,也可按自身需要本地化。两个约定: - 落到 `E_INTERNAL` 时,`message` 形如 `<可读信息> (<上游错误码>): <真实原因>`。上游把大量内部失败统一包成少数几个码(prompt 准入会包成 `session/agent-busy`),真实原因只存在于其 `details.reason`;括号里的原码与冒号后的原因仅供诊断,**不改变**该码的重试语义——`E_INTERNAL` 依旧不可自动重试。 - 其余码的 `message` 原样取自上游,不做加工。 Bridge 侧 Host code 到 wire 码的映射是 `src/wire-errors.ts` 的单一属主,逐码有测试冻结。命名空间码(`session/agent-busy`)与旧 host 的扁平写法(`agent-busy`)必须映到同一个 wire 码:只认一套会让另一半落进 `E_INTERNAL`,`E_BUSY` 于是永不出现,客户端的重试判断随之失真。 手机指令域(见「移动端系统命令」)的失败同样走这张表。桥接与插件自造的码与上游码同形,都在 `src/wire-errors.ts` 的 `command` 域逐条登记: | Host/Bridge code | wire 码 | 触发条件 | |---|---|---| | `command/not-found` | E_NOT_FOUND | 当前会话没有这个指令 | | `command/definition-changed` | E_NOT_FOUND | `expectedDefinitionId` 与当前目录不一致(缓存目录已过期) | | `command/attachments-unsupported` | E_PROTOCOL | 指令声明不接受附件,但请求带了附件 | | `command/export-unavailable` | E_INTERNAL | 主机未生成归档,或归档内容类型不符 | | `command/export-cancelled` | E_INTERNAL | 导出被取消,或超过十分钟上限 | | `command/invalid-response` | E_INTERNAL | 主机返回的目录/执行/反馈结果不合约定的形状 | | `command/export-unsupported` | E_UNSUPPORTED | 主机未挂载命令 API,导出能力不存在 | | `command/permissions-unsupported` | E_UNSUPPORTED | 主机未挂载权限预设服务 | | `gateway/bad-request` | E_PROTOCOL | 上游 Gateway 拒绝请求形状 | | `session/not-found`、`session/agent-busy`、`session/writer-held`、`session/conflict` | 同会话域取值 | 上游在指令路径上的原样拒绝 | 两处取值理由需要写明:`command/definition-changed` 归 `E_NOT_FOUND` 而不是 `E_BUSY`,因为目标定义已不存在,自动重试不会成功,客户端应重拉目录后让用户重选;`command/attachments-unsupported` 归 `E_PROTOCOL`,因为请求参数与指令自身的声明不符,与其余 payload 类拒绝同档。未登记的码仍然落 `E_INTERNAL`,并按本节约定把真实原因并进 `message`。 运行中排队与转向(见「运行中排队与转向扩展」)的队列域映射: | Host code | wire 码 | 触发条件 | |---|---|---| | `session/queue-item-not-found` | E_NOT_FOUND | 项已被领取、删除,或不存在 | | `session/steer-unavailable` | E_STEER_UNAVAILABLE | 当前轮已结束,或该项不在排队区 | | `session/agent-busy`(子代理会话) | E_BUSY | 沿用会话域现有映射 | ## 10. 能力协商与版本策略 - welcome.capabilities 中为 false 的能力,客户端不得调用对应 c2s 帧(服务端将回 E_UNSUPPORTED)。 - `notifyAllCategories` 是服务端投影保证而非新请求权限:缺失/false 表示客户端保留事件 通知回退,true 表示四类通知均由 `s2c.notify` 唯一负责展示。 - `deviceRevoke` 同 `liveActivityPush` 属可选 v2 扩展:true 表示 Bridge 接受 `c2s.device.revoke` 设备自撤销(见 §6)。缺失/false 时客户端不得发送该帧 (旧 Bridge 不认识该类型,回 `E_PROTOCOL`);客户端应就此结束该次解绑的重试,改为 本地抑制(不展示不属于任何已配对 Host 的推送,见 §6 通知 Host 路由)并可提示用户 在主机设置页吊销该设备,否则它只会在本地解绑、继续接收离线推送。 - v2 同版本新增可选字段时双方必须忽略未知字段。 - 旧 Host 不认识 `deeppilot/setDeviceName` 时,设置页应显示“不支持设备重命名”的降级提示, 不得把失败伪装成保存成功;新客户端仍须正常显示和删除设备。 - v1 不受支持;服务端不得接受 Bearer、`c2s.hello.auth` 或通过错误重试降级。 - 后续破坏性变更必须升级 `v`,不能静默重新解释 v2 字段。 ### 通知 Host 路由(向后兼容扩展) `s2c.notify` 与 APNs/Relay notification 可携带可选 `hostAudience`,值为配对时 登记的稳定 Host audience(`deeppilot:` 加 22 位 base64url)。Bridge 在发送时填写, Relay 校验并原样转发。App 根据该值匹配本地实例,不能用当前选中实例代替来源。 多个本地配置匹配时,通知点击由用户选择;后台审批必须唯一匹配并重新获取 pending。 旧 Bridge/Relay 没有该字段时,仅单实例可自动路由,多实例点击需选择;未知或已删除 Host 不自动回退。旧客户端可忽略新增字段,协议版本及其他字段保持不变。 APNs collapse/thread 标识在存在 hostAudience 时按 Host 隔离;通知原始 notificationId 保持不变,以继续解析旧审批的 `apr-`。 ### 客户端请求字段验证 已认证的请求在业务调用前验证 payload 必须为对象;无参数请求允许省略 payload。 可选字段若提供则必须符合声明类型,不把错误类型当作未提供。新增未知字段仍忽略。 标识符最多 4096 字符,路径最多 32768 字符,标题最多 4096 字符;模型 provider/model/ reasoningEffort 分别最多 256/1024/128 字符。history.beforeSeq 必须为非负安全整数, limit 为 1–500 的整数;tailCount 为 1–10000 的整数。审批 reason 和提问 custom 最多 65536 字符;answers 及每项 selected 最多 100 项,answer id 不能重复。 APNs environment 若提供只接受 development/production,categories 的值必须为布尔值。 非法字段返回 E_PROTOCOL;权限不足仍优先返回 E_FORBIDDEN。既有附件及正文预算继续生效。 ### 稳定发送身份与投递回执(可选 v2 扩展) `welcome.capabilities.promptDelivery = true` 表示支持以下契约。旧客户端仍可省略 `clientSendId`,沿用旧 ack;新客户端对不支持该能力的 Bridge 保留兼容路径。 - `c2s.session.sendPrompt` 可增加 `clientSendId`,格式为 13 位毫秒时间戳、连字符、UUID。 同一发送的 ID、会话、正文和附件在重试时必须保持不变。 - ID 以已认证设备为命名空间,不能用帧 `id` 代替。相同设备+ID+内容只调度一次; 并发重复请求复用回执。同 ID 不同内容返回 rejected/E_PROTOCOL。 - 携带该字段时 `s2c.ack` payload 为 `{clientSendId, status, userSeq?, code?}`。 status 为 accepted/rejected/unknown/notFound/expired。accepted 表示上游已受理, 不表示 turn 完成;userSeq 仍仅为回执标记,不能与消息 seq 对账。 - `c2s.session.delivery` payload 为 `{sessionId, clientSendId}`,返回相同回执结构, 要求 `prompt.send` 权限且只能查询本设备的记录。查询没有副作用。 - Bridge 在调度上游前原子写入并同步本地 journal,再保存最终结果。中断、内部错误或 存储异常均保留 unknown,不重放可能已经执行的请求。journal 不应被手动删除; 数据目录由单一 Host 进程拥有,同进程 Bridge 重建共享 journal。 - 新 ID 允许至多 5 分钟未来时钟偏移和 7 天回溯。超过 7 天的旧 ID 不再触发新调度。 journal 最多保留 10000 项;未过期项不会为腾出空间被淘汰,容量满时拒绝新发送。 - App 在网络请求前保存待确认 ID、显示正文和附件数量,最多每实例 100 项;不保存 自动重试所需的图片原始数据。重连、前台会话轮询会查询回执;accepted 清除待确认 记录,rejected 显示明确拒绝,notFound/expired/unknown 均不自动重发。 新路径不以相同正文或附件数量认定投递成功。 此契约提供有持久化记录时的至多一次调度,不宣称在上游不支持幂等键的情况下实现 崩溃后的 exactly-once。若上游已接收而 Bridge 来不及记录结果,需用户核对会话。 ## Live Activity updates (optional protocol-v2 extension) Live Activities are limited to sessions with a nonempty TODO checklist: use `todos.total > 0`, falling back to `todoItems.length` only when the count is absent. Starting also requires running or pending-interaction state. Ordinary chat does not create an activity. Clearing the checklist projects `unavailable` and ends an existing activity; completing a round with a retained checklist continues to use `ended`. This policy does not change protocol-v2 wire fields. `WelcomeCapabilities.liveActivityPush: true` advertises registration support; `push` still indicates whether the configured delivery provider is ready. Older clients ignore the new capability; new clients omit these requests when it is absent and may retain foreground-only ActivityKit updates. - `c2s.liveActivity.register` → `s2c.ack { enabled: boolean }`: payload contains `activityId` (1–128 characters), `sessionId`, `deviceToken` (32–512 hex characters), `environment` (`development` or `production`), and optional `enrollKey`. This is the individual ActivityKit **update** token, never an alert, WidgetKit, or push-to-start token. Requires `notifications.register`, `sessions.read`, and `interactions.respond`; authorization is rechecked after enrollment. The session must exist. An activity cannot change sessions. - `c2s.liveActivity.unregister` → `s2c.ack {}`: `{ activityId }`, scoped to the authenticated device. A stale activity ID cannot remove a newer registration. - One registration per device; a new activity replaces the old registration. Registrations expire after eight hours. Token rotation preserves expiry and the terminal state. Revocation clears the registration. Terminal state is persisted before batching and cannot be revived by a subsequent round. Push delivery adds `kind: "liveactivity"`, `event: "update" | "end"`, `timestamp` (Unix seconds), and `contentState: { title, task, done, total, phase }`. Title and current-task text (current tool operation when available, otherwise the checklist item) are limited to 100 and 160 Unicode code points. Counts are nonnegative integers, with `done <= total`; phase is `running`, `approval`, `question`, `ended`, `failed`, or `unavailable`. `failed` is projected when the session status is `error` and, like `ended` and `unavailable`, is terminal (sent as an `end` event). A client that does not know `failed` must treat it as `unavailable`. Deploy order: Relay, then plugin, then App; an App that predates `failed` shows an untranslated label. End does not imply all todos completed. APNs uses the main app topic suffixed `.push-type.liveactivity`, push type `liveactivity`, and `aps.content-state`. Updates have a 180-second stale date; end events dismiss after 300 seconds. Bursts coalesce with a 15-second minimum interval; failed sends retry while the registration remains valid. There is no push-to-start and no continuous network connection inside the activity. Relay mode carries only this bounded progress projection, never conversation history, attachments, or full todo lists. It rejects unknown content-state fields, oversized text, invalid counts, stale timestamps, and event/phase mismatches. The updated Relay must be deployed before background Live Activity updates work through relay mode. System delivery and refresh budgets still apply. ## Schedule 定时任务扩展(可选 v2 能力) `welcome.capabilities.schedules=true` 表示 Host 挂载了 DSH Schedule service。 注意:该 service 由宿主组合决定,手机端无需用户先做任何开关操作。DSH 0.2.0 曾把 它从出厂 Web composition 移出、改由可选 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 提供(默认关闭),但紧接着的版本 又把该 bundle 退役:`@deepseek-ai/dsh-web-app` 现在在每个 Web profile 中挂载 `schedule` 与 `ui-schedule`,加载 profile 时会从 `dsh.profile.bundles` 中删除该 退役条目。因此本能力位反映的是实际挂载结果,而非宿主版本——未挂载时为 `false`。 所有 Schedule 请求都要求设备 scope `schedule.manage`;旧客户端忽略未知 capability 和未知帧。没有该能力时请求返回 `E_UNSUPPORTED`。 ### 列表与历史 ```json { "type": "c2s.schedule.list", "id": "sl-1", "payload": { "sessionId": "session-…" } } { "type": "s2c.schedule.snapshot", "id": "sl-1", "payload": { "sessionId": "session-…", "tasks": [] } } ``` ```json { "type": "c2s.schedule.history", "id": "sh-1", "payload": { "sessionId": "session-…", "id": "schedule-…", "limit": 20, "before": "message-…" } } { "type": "s2c.schedule.history", "id": "sh-1", "payload": { "sessionId": "session-…", "history": { "id": "schedule-…", "records": [] } } } ``` `limit` 必须是 1–100 的安全整数;`before` 是上一页返回的最旧 message id,且为 排他游标。任务和历史只能访问请求中明确给出的 Session。 ### 创建、更新、删除 ```json { "type": "c2s.schedule.create", "id": "sc-1", "payload": { "clientRequestId": "1730000000000-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "sessionId": "session-…", "title": "检查构建", "prompt": "检查构建状态", "after_seconds": 600 } } ``` 创建必须提供非空 `title`、非空 `prompt`,并且恰好提供一个 selector: - `after_seconds`:正安全整数; - `at`:未来绝对时间或带 `date`、`time`、`time_zone` 的对象; - `every_seconds`:至少 60 秒的安全整数; - `daily` / `weekly` / `cron`:对应 DSH Schedule 结构,Host 负责完整校验。 更新使用 `c2s.schedule.update`,必须携带 `clientRequestId`、`sessionId`、`id` 和最近一次读取的完整 `expected` 记录;可选 `title`、`prompt` 和 `change` 分别 替换名称、提醒内容或时间规则。过期 `expected` 返回 `E_BUSY`(conflict),不得 静默覆盖 Host 中的新版本。 删除使用 `c2s.schedule.delete`,必须携带 `clientRequestId`、`sessionId` 和 `id`。 三类变更成功统一回 `s2c.schedule.updated`: ```json { "type": "s2c.schedule.updated", "id": "sc-1", "payload": { "clientRequestId": "1730000000000-…", "sessionId": "session-…", "task": { "id": "schedule-…" } } } ``` 删除响应使用 `deleted:true`。同一设备重复发送相同 `clientRequestId` 和相同内容时 不得重复执行;Host 只返回 `replayed:true`。相同 ID 搭配不同内容返回 `E_PROTOCOL`。结果未知时不得自动重试,用户应重新读取任务列表确认。 DSH `schedule/changed` 会被投影为无正文的 `s2c.schedule.changed`,客户端收到后 重新请求当前可见 Session 的任务列表。APNs/Relay 不得携带提醒 prompt。 ## Session 分支扩展(可选 v2 能力) `welcome.capabilities.sessionFork=true` 表示 Host 暴露 DSH Session fork。 请求要求 `sessions.read` 与 `sessions.manage`;旧客户端忽略该 capability。 ```json { "type": "c2s.session.fork", "id": "fork-1", "payload": { "clientRequestId": "1730000000000-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "sessionId": "session-…", "atSeq": 42 } } { "type": "s2c.session.forked", "id": "fork-1", "payload": { "clientRequestId": "1730000000000-…", "sourceSessionId": "session-…", "sessionId": "new-session-…", "atSeq": 42 } } ``` `atSeq` 是源会话中用户可见的精确消息/事件边界;省略时 DSH 选择最新完成轮次。 Host 必须确认源会话是普通、可见且未归档的会话;subagent、归档会话、未知 `atSeq` 和不存在的源会话均不得创建本地假分支。 同一设备的 `clientRequestId` 和相同请求内容只能执行一次。重启后 Host 从独立 fork journal 恢复已创建的新 Session id,并返回 `replayed:true`;同一 ID 搭配不同 内容返回 `E_PROTOCOL`。原会话永远不被修改或归档。 ## 运行中排队与转向扩展(可选 v2 能力) `welcome.capabilities.promptQueue=true` 表示 Host 同时提供 DSH `session.updateQueue` 与 `inbox` 投影。缺失或为 false 时,客户端不得发送 `mode:"steer"` 与 `c2s.session.queue.update`(Bridge 回 `E_UNSUPPORTED`),也不会收到 `s2c.session.queue`;会话运行中的发送保持旧行为。旧客户端忽略该能力位。 子代理会话不提供队列:Bridge 不为其推送快照,对其请求按 Host 的拒绝返回。 ### 发送模式 - `queue`:进入下一轮(Host `next-turn`),当前轮结束后按先进先出执行。空闲会话的 提示词同样先入队再被立即领取,因此出现在队列里不代表会话正在运行。 - `steer`:在当前轮的下一个步骤边界插入(Host `next-step`)。客户端只应在会话运行中 使用;空闲时的处理以 Host 为准。 - 携带 `clientSendId` 时,`mode` 计入同 ID 的内容比对;同 ID 不同 `mode` 返回 rejected / `E_PROTOCOL`。Bridge 以 `dp:` 加 `clientSendId` 作为 Host `requestId`, Host 对已入队或已写入日志的同一 `requestId` 重试,直接返回原受理结果,不重复插入。 ### s2c.session.queue(推送) ```json { "type": "s2c.session.queue", "payload": { "sessionId": "session-…", "items": [ { "itemId": "msg-…", "placement": "queued", "text": "第二条", "imageCount": 0, "documentCount": 0, "clientSendId": "1730000000000-…" }, { "itemId": "msg-…", "placement": "steering", "text": "先别改测试", "imageCount": 1, "documentCount": 0 } ] } } ``` - 整表快照:每次入队、转向、删除、被领取或丢弃后,推送该会话当前的完整队列。客户端 整体替换,不做增量合并。 - 推送范围:与 `s2c.session.event` 相同,变化时广播给所有具备 `sessions.read` 的连接, 不区分是否打开了该会话。`c2s.session.open` 时 Bridge 另在 `s2c.session.tail` 之后补发 一次当前快照(空队列也发),这一帧与 tail 同属打开响应,不占 seq。 - `items` 保持 Host 的先进先出顺序。`placement` 为 `queued`(Host `next-turn`)或 `steering`(Host `next-step`);Host 的 `context` 项不下发。 - `itemId` 是 Host `MessageId`,供 `c2s.session.queue.update` 使用。 - `text` 是用户正文,不含 Bridge 生成的文档正文块,最多 4096 个 Unicode 码点,超出时 截断并置 `truncated:true`。`imageCount` / `documentCount` 为附件数量;非本 Bridge 格式的来源(如 Web 端)`documentCount` 为 0。 - `clientSendId` 在该项由 DeepPilot 客户端经 `clientSendId` 发送时出现(从 Host 来源 `rpcId` 去掉 `dp:` 前缀得到),所有设备收到的快照相同。它只供发起设备把待发送项与 队列项对上;不是凭据,`c2s.session.delivery` 按设备隔离,其他设备拿到也查不到回执。 - 转向在 Host 侧分两次变化完成:先离开 `next-turn`,再进入 `next-step`。Bridge 可以 合并同一会话短时间内的连续变化,但客户端仍须按 `itemId` 跨两种 `placement` 跟踪, 不得把单帧缺席视为删除。 - 变化推送占 seq、进入重放环,下行权限为 `sessions.read`。重放时客户端以同一会话的 最后一帧为准;收到 `s2c.resync` 后重开会话即获得新快照。 ### c2s.session.queue.update ```json { "type": "c2s.session.queue.update", "id": "q-1", "payload": { "sessionId": "session-…", "itemId": "msg-…", "action": "steer" } } { "type": "s2c.ack", "id": "q-1", "payload": { "sessionId": "session-…", "itemId": "msg-…" } } ``` - 需要 `prompt.send` 权限。`action` 为 `remove` 或 `steer`;`edit` 为预留值,本版本 返回 `E_UNSUPPORTED`。 - `remove`:取消该项,Host 同时释放其上传附件。 - `steer`:把排队项改为在下一个步骤边界插入,只对 `placement:"queued"` 且会话运行中 有效。 - ack 只表示 Host 已提交该变更;真实状态以随后的 `s2c.session.queue` 为准。 - 不保证幂等:项已不存在时返回 `E_NOT_FOUND`。客户端不自动重试,以下一帧快照为准。 - 转向不可用时返回 `E_STEER_UNAVAILABLE`。该码不可自动重试:项通常仍在 `queued`, 客户端应提示「本轮已结束,消息保留在队列中」,并以下一帧快照为准。只有声明了 `promptQueue` 的 Bridge 才会返回此码,所以不认识它的旧客户端不会遇到。 ## 实时流式输出扩展(可选 v2 能力) `welcome.capabilities.assistantStream=true` 表示 Bridge 会把模型的增量输出作为 `s2c.session.delta` 推给已打开该会话的连接。缺失或 false 时客户端不得依赖该帧,渲染 退回「整步落盘后才出现文字」的旧行为;旧客户端忽略未知帧。 ### s2c.session.delta(推送) ```json { "type": "s2c.session.delta", "payload": { "sessionId": "session-…", "streamId": "stream-…", "kind": "message.delta", "text": "增量片段", "ts": 1730000000000 } } ``` - 权限 `sessions.read`;只推给已经 `c2s.session.open` 该会话的连接,不做广播。 - **不占 seq、不进重放环、断线不补发**。这是它与 `s2c.session.event` 的根本区别:后者 投影持久事件并占用重放序号,前者是瞬时流量。 - `kind` 为 `stream.start`、`message.delta`、`thinking.delta` 或 `stream.end`。生命周期帧 的 `text` 为 `""`。`stream.start` 在每次模型尝试开始时由 Bridge 生成随机 `streamId`; 不能把会在 Agent 恢复后重置的宿主 attempt 计数直接用作全局流标识。 - 服务端按 50 ms 窗口有序合并同一流同一文字类别的增量,单帧正文上限 256 KiB, 超出截断并置 `truncated:true`;推理与正文切换时不得重新排序。final 与 end 之前先 发出尚在合并窗口内的内容,正式行之后不得再发迟到的增量。 - 宿主持久 `assistant/message` 先于 committed end 发出,它经 `s2c.session.event` 的 `message.final` 送达,`data.streamId?` 关联同一次尝试的临时行。客户端用正式行替换 该流临时内容。只有 final 没有 delta(旧宿主,或打开会话时已在运行)是正常情况。 - `stream.end` 带 `outcome:{kind:"committed",eventType:"assistant/message"|"assistant/attempt",seq}` 或 `outcome:{kind:"abandoned"}`。`seq` 在这里指宿主持久日志中的事件序号,不是 信封重放序号;弃置尝试及仅记录 attempt 的尝试必须清理临时行,不能等待不存在的 final。 - 客户端在收到 `turn.end`、取消成功回执、连接断开或 abandoned end 时,必须丢弃 未完成临时流;临时内容不写入正式消息历史或磁盘缓存。 - 子代理会话不推送该帧。 ## 插件自更新扩展(可选 v2 能力) `welcome.capabilities.pluginUpdate=true` 表示 Bridge 允许手机触发「把本插件更新到某个 已发布版本」。缺失或 false 时客户端不得发送下列 c2s 帧(旧 Bridge 回 `E_PROTOCOL`)。 ### 协议闸门 Bridge 在触发更新前必须核对三件事,任一不满足即拒绝,不做部分更新: 1. 目标版本存在于 npm 且声明了 bundle; 2. 目标版本接受当前运行的 DSH 版本(宿主 `listVersions` 的对应项未带 `incompatible`, 转给手机时投影为 `compatible`); 3. 目标精确版本的 `package.json` 声明 `deepPilot.protocolVersion` 等于当前整数 `protocolVersion`。该声明通过注册表元数据读取并核对包名与版本;缺失或格式非法时拒绝。 第 3 条是 App↔插件闸门,宿主本身不校验它。 安装前按宿主实际软件源计划核验全部可能的回退来源。每个来源的精确目标包名、版本、 协议声明及强 SRI 完整性摘要必须一致,任一源不可核验或内容分歧都拒绝受理;不会修改 全局软件源或把私有源扩为公共回退。检查界面可先显示单源候选预览,apply 仍以完整预检 为准。 ### c2s.plugin.update.check → s2c.plugin.update.status 权限 `sessions.read`,widget 不可用。请求 `{}`。响应载荷: ```json { "type": "s2c.plugin.update.status", "payload": { "currentVersion": "0.9.12", "protocolVersion": 2, "latestVersion": "0.9.13", "updateAvailable": true, "compatible": true, "protocolCompatible": true, "versions": [{ "version": "0.9.13", "compatible": true, "protocolVersion": 2 }], "checkedAt": 1730000000000 } } ``` - `versions` 最多 10 项,新版本在前;`compatible` 是宿主对 DSH 兼容性的判断, `protocolVersion` 来自目标包声明,缺失时该版本不可选。 - 读取失败时 `latestVersion` 为 `null` 并附 `reason`,不抛错——离线是可预期的常态。 - 最近一次更新日志额外返回 `journal: {clientRequestId,fromVersion,toVersion,phase,startedAt,message?}`。 成功、回滚与失败终态都保留,手机用请求编号对账;处于中间态时显示正在更新,不能把断线 或尚未自检完成解读为失败。 ### c2s.plugin.update.apply 权限 `sessions.manage`,widget 不可用。请求 `{version, clientRequestId}`。客户端生成并保留 唯一请求编号,最长 128 个 ASCII 字母、数字、点、下划线、冒号或连字符;App 沿用 13 位时间前缀加 UUID。Bridge 先完成目标校验及更新日志耐久写入,才回 `s2c.ack {clientRequestId, accepted:true}`。同编号同目标复用原状态,不重复安装;同编号不同 目标返回 `E_PROTOCOL`,另一更新正在进行时返回 `E_BUSY`。随后用 `s2c.plugin.update.progress` 推进度: ```json { "type": "s2c.plugin.update.progress", "payload": { "clientRequestId": "…", "phase": "installing", "percent": 0, "message": "中文诊断文本" } } ``` - `phase` 语义:`preparing` 校验目标;`installing` 宿主正在替换包——启用 HMR 时本插件 会被卸载,**连接会断开,这是预期行为**;`verifying` 新版本已加载并自检; `restart-required` 表示宿主未启用 HMR、需要用户重启 DSH;`done` 更新完成; `rolled-back` 表示安装失败且宿主已自动装回旧版本;`failed` 表示失败且未能回滚。 - 手机断开后重连应先查 `c2s.plugin.update.check`,用其中的 `journal` 判定上次结果, 不要假设连接断开等于失败。 - 该帧不进入重放环。 ### 更新日志(重启安全) Bridge 在开始安装前把 `{clientRequestId,fromVersion,toVersion,phase,startedAt,message?}` 耐久写入插件当前数据目录的 `update-journal.json`(默认 `/deeppilot/update-journal.json`,尊重自定义及隔离配置)。保留最近一次的成功、 回滚或失败终态,新请求覆盖;不保存设备、凭据或会话内容。连接关闭不得取消宿主正在执行 的更新,新实例启动后按当前实际版本及宿主结果恢复日志。旧实例异步回调不得覆写新请求。 这里的“重启恢复”指宿主 HMR 卸载旧插件、加载新插件后手机自动重连并恢复会话。宿主无 HMR 时只能报告 `restart-required`,不强杀 DSH 进程,也不声称已自动重启。 ### 回滚 回滚由宿主的 `updateBundle` 负责:HMR 路径下安装失败或取消会自动装回旧版本。Bridge 不 自行回滚,只把宿主报告的结果映射成上面的 `phase`。未启用 HMR 时宿主报告需要重启, Bridge 必须返回 `restart-required` 而不是假装完成。 ## 移动端系统命令(v2 可选扩展) `welcome.capabilities.commands?: boolean` 缺省为不支持。手机从当前会话的官方命令目录发现指令;目录名称、`definitionId`、`input.hint` 和 `input.attachments` 原样投影,不传处理器。命令不走普通 prompt 通道,执行生命周期由主机记录。所有新帧均拒绝 widget。 ### c2s.session.commands 权限 `sessions.read`。请求 `{sessionId}`,响应 `s2c.session.commands {sessionId,commands:[{name,description,definitionId?,input?:{hint,attachments?}}],permissions:boolean,export:boolean}`。名称按主机 Agent 作用域覆盖后的结果读取。`s2c.commands.changed {sessionId}` 表示该会话的命令或权限目录失效,负载恒带 `sessionId`:权限目录与命令目录共用这一条推送,客户端不区分来源,收到后重拉两个目录。连接、会话预设变化同样撤销旧结果。 ### c2s.session.command 权限 `prompt.send`。请求 `{sessionId,line,attachments?:[{type:"image"|"document",name,mediaType,data}],expectedDefinitionId?:string}`,响应 `s2c.session.commandResult {sessionId,commandId,result:{kind:"success"|"error",text?,sourceEventSeq?}}`。`line` 必须为草稿起始命令行,格式为 `/` 加命令名,命令名匹配 `[a-z][a-z0-9_-]*` 且其后必须是空白或字符串结尾,总长最多 64 KiB 字符;保留命令名后的原始空白和参数,两端都不 trim、也不折叠空格。附件最多 4 个,每项 base64 最长 16 MiB;图片限 PNG/JPEG/WebP/GIF。`document` 是手机已提取的 UTF-8 正文,以文本文件上传,绝不声称是原始 PDF/二进制。主机先核对命令声明的附件支持,再上传及执行。未知命令不可回退为模型消息。客户端不自动重试执行;网络中断可能无法确认主机结果。 ### c2s.session.permissions 权限 `sessions.read`。请求 `{sessionId}`,响应 `s2c.session.permissions {sessionId,currentValue?,options:[{value,name,description?,confirmation:boolean}]}`。当前值来自 `permissions` 投影;目录来自 `permissionPresets/catalog`。选择器对完全权限与实验自动审查显示确认,选中后执行 `/permission `;显式完整命令直接交主机校验。 ### c2s.session.feedback 权限 `prompt.send`。请求 `{sessionId,text,category?}`,类别限主机固定的七种反馈类别,正文最长 64 KiB 字符。通过官方 `sessionFeedback/record` 记录会话反馈,响应 `s2c.ack {sessionId}`。该操作只记录本机主机的会话日志,不自动提交模型提示词或向外部平台发送。 ### c2s.session.export 权限 `sessions.read`。请求 `{sessionId, format?}`,`format` 取 `"zip"`(缺省)或 `"markdown"`。`zip` 使用官方 `/api/session.export?includeDescendants=true` 归档路由,返回含子会话、图片和文件的完整 ZIP;`markdown` 使用官方 `/api/session.markdown?sessionId=…` 路由,返回 `text/markdown` 的对话正文。两种格式共用同一套分块与回执:连续响应 `s2c.session.export.chunk {index,data}`,从 0 连续编号,每块最多 48 KiB 二进制的 base64;同一请求 `id` 关联所有块,块不是最终响应。最终 `s2c.ack {sessionId,fileName,chunks,bytes,mediaType?}` 核对完整性,`mediaType` 为 `application/zip` 或 `text/markdown; charset=utf-8`。错误为同 id 的 `s2c.error`,客户端删除未完成临时文件。传输按连接写缓冲等待,断线取消归档流;块不进入重放环。服务端对整次导出设十分钟上限,超时同样取消归档流、按同 id 的 `s2c.error`(E_INTERNAL)结束,且不发送 `s2c.ack`——部分块永远不构成成功的导出。手机按块落临时文件、核对编号和最终大小,成功后才打开系统分享。 `format:"markdown"` 需要 `welcome.capabilities.sessionMarkdown=true`,缺失或 false 时 Bridge 回 `E_UNSUPPORTED`;客户端必须在发送前读取该能力位,不能只凭请求成功与否判断——不认识 `format` 的旧 Bridge 会忽略该字段并照常返回 ZIP。Markdown 正文省略 reasoning、system/developer 消息与注入的非人类上下文,菜单文案必须写成「导出对话正文」而不是「导出完整日志」。缺省 `format` 时行为与本扩展出现前逐字节相同。 模型与文件入口由手机自身提供,并复用已有选择器;权限、反馈仅装饰匹配的官方 `definitionId`,同名第三方命令仍保留自己的执行与参数语义。技能继续补全文字后作为 prompt 发送;同名候选按类型分开,显式选择技能优先保留技能语义。 ### s2c.models.changed 需要 `sessions.read`。主机 `modelSelection` 会话投影变化时推送 `{sessionId,current?:{provider,model,reasoningEffort?}}`;字段只投影公开模型选择,不包含其他上游数据。缺少 `current` 表示重新读取权威选择。它使对应会话的模型目录缓存失效;当前选择与可选模型列表分别更新,防止旧目录覆盖另一端的实时切换。旧客户端可忽略未知帧,旧桥接由短有效期与手动刷新兜底。 原生权限操作可在 `c2s.session.command` 携带 `expectedDefinitionId`;插件重新读取目录并精确核对来源后才执行。定义变化时拒绝执行,不把缓存中的界面操作错误转给新的同名命令。此字段可选,最长 512 字符,不影响已有调用。