DeepPilot Bridge Protocol
September 7, 2026 · View on GitHub
状态:v2 唯一支持基线。所有帧均为 WebSocket 文本帧,UTF-8 编码的 JSON。
v2 不接受 v1 Bearer Token、c2s.hello.auth 或任何降级握手;升级后设备必须重新配对。
1. 信封(Envelope)
{
"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.1 一次性配对
设置页在用户主动操作后生成 24 字节随机配对码,base64url 编码、5 分钟过期、成功使用 一次后立即失效。二维码格式:
{
"v": 2,
"type": "deeppilot-pairing",
"host": "https://example.funnel.ts.net",
"code": "<single-use base64url code>",
"expiresAt": 1756000300000,
"audience": "deeppilot:<stable host id>"
}
App 为该主机生成 P-256 Signing 私钥;真机优先使用 Secure Enclave,私钥不得离开设备。
App 向 POST /phone/pair 发送:
{
"v": 2,
"code": "<single-use 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:
{
"ok": true,
"v": 2,
"deviceId": "<base64url SHA-256 of raw public key>",
"audience": "deeppilot:<stable host id>",
"scopes": ["sessions.read", "prompt.send", "sessions.manage", "interactions.respond", "notifications.register"]
}
App 扫码时必须确认响应 audience 与二维码一致。配对码无效、过期或已使用返回 401;
频率限制返回 429;设备注册表满返回 409。GET /phone/health 只返回最小公开状态,
不承担鉴权。
2.2 WebSocket 挑战签名
- 客户端连接
wss://host/phone,HTTP Upgrade 不携带凭据。 - 服务端立即发送
s2c.auth.challenge:
{ "v": 2, "type": "s2c.auth.challenge", "payload": {
"nonce": "<24-byte base64url random>",
"audience": "deeppilot:<stable host id>",
"issuedAt": 1756000000000,
"expiresAt": 1756000030000
} }
- 客户端确认
audience与配对记录一致,并在 30 秒内发送c2s.auth.prove:
{ "v": 2, "type": "c2s.auth.prove", "payload": {
"nonce": "<challenge nonce>",
"audience": "deeppilot:<stable host id>",
"issuedAt": 1756000000000,
"expiresAt": 1756000030000,
"deviceId": "<registered device id>",
"deviceName": "iPhone 15 Pro",
"appVersion": "0.1.0",
"resumeCursor": 1042,
"clientRole": "widget",
"signature": "<ASN.1 DER ECDSA P-256/SHA-256 signature, base64url>"
} }
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;时间戳和游标
必须是十进制有限整数,缺失游标写 -:
deeppilot-auth-v2
device-id:<base64url(deviceId UTF-8)>
nonce:<nonce>
audience:<base64url(audience UTF-8)>
issued-at:<issuedAt>
expires-at:<expiresAt>
device-name:<base64url(deviceName UTF-8)>
app-version:<base64url(appVersion UTF-8)>
resume-cursor:<resumeCursor or ->
- 服务端仅接受注册、未撤销且签名有效的设备。失败以 4401 关闭;超时以 4402 关闭;版本不匹配以 4500 关闭。welcome 前客户端只允许发送 proof 与 ping。
- scope 在每次认证时载入内存;scope 变更或撤销会立即断开该设备,重连后重新判定。
scope 与操作映射:
| scope | 允许的操作 |
|---|---|
sessions.read | 会话/工作区/目录读取、会话打开与历史、模型目录读取 |
prompt.send | c2s.session.sendPrompt |
sessions.manage | 创建/重命名/归档/取消会话,创建工作区,切换模型 |
interactions.respond | pending 快照、回答 approval 与 question |
notifications.register | 注册 APNs token 与通知偏好 |
缺少所需 scope 时服务端回 E_FORBIDDEN,连接保持打开。当前产品默认配对授予
全部 scope,普通设置页不提供逐项权限控制,只提供全局连接开关和逐设备删除。
c2s.ping 与 c2s.resume 是已认证连接的控制帧,不额外要求业务 scope。
下行(S→C)广播权限
scope 同样约束服务端推送给已认证连接的内容。桥在每次广播与重放时按帧类型 执行下行权限判定;未满足 scope 的帧不下发,但计入 seq 游标并加入重放环,使 权限收紧不影响其他设备的投递。
| 下行帧类型 | 需要的 scope |
|---|---|
s2c.session.event、s2c.sessions.delta、s2c.session.tail、s2c.history.page | 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(正文可能包含助手输出) |
| welcome / ack / error / challenge 及对已通过 scope 检查的 c2s 请求的点对点响应 | 无 |
未知广播类型默认不下发。重放按每帧权限过滤,不要求设备拥有 sessions.read; 有效 cursor 的重放以 s2c.resume.done 结束,过期 cursor 下发 s2c.resync。 这些控制帧不携带业务内容。仅 interactions.respond 的设备可通过 pending 快照恢复。 APNs 必须同时具备 notifications.register 和对应通知类别的内容权限; 单独的注册权限不授予通知正文读取权限。点对点响应由对应请求权限保护。
s2c.welcome
{ "type": "s2c.welcome", "payload": {
"protocolVersion": 2,
"serverVersion": "0.1.0",
"deviceId": "<authenticated device id>",
"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 },
"cursor": 1042,
"resumed": true
} }
3. 会话列表
c2s.sessions.list → s2c.sessions.snapshot
{ "type": "s2c.sessions.snapshot", "payload": { "full": true, "sessions": [SessionSummary] } }
SessionSummary:
{
"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,客户端必须容忍并回退。
列表变更推送(握手后自动开始,无需订阅):
{ "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
{ "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 投影:
{
"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的messagesJSON 数组不超过 900KB;超出时保留最接近请求边界的较新后缀,并令hasMore: true,客户端继续分页即可完整取回,禁止发送一个超大整帧后让客户端静默丢弃。 attachments:仅 user 行携带的附件清单。图片的kind=image,attachmentId是宿主附件服务的持久引用, 供 c2s.session.attachment 读回原图;width/height为像素尺寸(可选,供客户端预留布局)。 文本文档的kind=document,由 Bridge 以带边界的模型文本提交;当前 Host 没有通用二进制附件服务, 因此文档不提供 attachmentId/read-back。truncated=true表示客户端只提交了可读文本前缀。 attachmentId/宽高为可选字段,客户端必须容忍缺失。
会话模型目录与切换
模型列表必须由 Host 的 session.models 动态提供,DeepPilot Bridge 和 App 不得内置供应商、模型名称或思考强度选项。welcome.capabilities.models=false 时客户端保留入口并显示明确的能力不可用状态。
{ "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。
{ "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。
{ "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 匹配。 - turn.end 的 data 附带 ok 布尔;projection 的 data 为 key/value(如 todos)。
4b. 项目选择与新建会话
welcome.capabilities.projectSelection=true 表示 Bridge 同时具备 Host workspace.list/create 能力。新建会话界面先列出现有项目:
{ "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 给出的绝对路径,不自行拼接路径:
{ "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;客户端可在用户明确点击后请求 Mac 系统选择器。取消时 path=null:
{ "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):
{ "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 订阅。
{ "type": "s2c.ack", "payload": { "sessionId": "session-…" } }
projectSelection=false 时,客户端保留入口并明确提示能力不可用,不得静默创建到 Host 默认目录。
4c. 会话管理
会话重命名真实调用 Host session.rename,成功后返回 Host 接受的规范化名称:
{ "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 从列表镜像移除。
{ "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,客户端不得伪造本地成功状态。
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/压缩包等无法安全提取文本的格式必须由客户端拒绝,不得假装上传成功。
{ "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"}]
} }
c2s.session.attachment(payload: sessionId, attachmentId)
按 id 读回一张会话引用过的持久化图片(宿主校验该会话日志确实包含此 id 后才返回)。
RPC 响应 result 为 { "mediaType": "image/jpeg", "data": "<base64>" };宿主不可用或 id 无效回 E_NOT_FOUND。
客户端应做磁盘缓存,避免重复拉取。
{ "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。
{ "type": "c2s.session.cancel", "id": "c-1", "payload": { "sessionId": "session-…" } }
{ "type": "s2c.ack", "id": "c-1", "payload": { "sessionId": "session-…" } }
审批
{ "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。
- 挂起审批同时体现在 SessionSummary.pendingApproval。
提问
{ "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 后应主动请求当前待处理快照;这条路径不依赖有限长度的重放环。
{ "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.1.2-alpha.3 起,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. 通知
{ "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 文案判断类别。
离线推送(APNs)
welcome.capabilities.push=true 表示 Bridge 已配置 APNs。App 在获得系统远程通知
token 后发送:
{ "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.registerscope——即使持有 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" 的短连接发送:
{ "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.respondscope(小组件概览同时包含会话与待处理计数)。 - 载荷完全无内容: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 分发模式同样透传该推送,中继只做转发与限流。
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 关闭; 客户端可按临时过载执行退避重连。
9. 错误帧
{ "type": "s2c.error", "id": "<ref>", "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 | 服务端内部错误 |
10. 能力协商与版本策略
- welcome.capabilities 中为 false 的能力,客户端不得调用对应 c2s 帧(服务端将回 E_UNSUPPORTED)。
notifyAllCategories是服务端投影保证而非新请求权限:缺失/false 表示客户端保留事件 通知回退,true 表示四类通知均由s2c.notify唯一负责展示。- v2 同版本新增可选字段时双方必须忽略未知字段。
- 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-<requestId>。
客户端请求字段验证
已认证的请求在业务调用前验证 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.ackpayload 为{clientSendId, status, userSeq?, code?}。 status 为 accepted/rejected/unknown/notFound/expired。accepted 表示上游已受理, 不表示 turn 完成;userSeq 仍仅为回执标记,不能与消息 seq 对账。 c2s.session.deliverypayload 为{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 来不及记录结果,需用户核对会话。