# 传输契约(冻结):命令通道 RPC ## 为什么用命令通道 DSH 的浏览器↔Host 通道是 Typert Remote,命名空间由 **build-time 生成器**产出(`*/lib/typert.remote-client.d.ts` 头部写着 "Generated by @deepseek-ai/dsh-typert-generator … do not edit")。第三方插件没有这个生成器,因此**无法**新增自有 Remote 命名空间。 但官方已有 `remote.commands`,它恰好是一个**通用双向调用通道**: ``` commands.execute(agentId, line, submittedAttachments, signal) : Promise> CommandExecution = { commandId, result: { kind:'success', text?, sourceEventSeq? } | { kind:'error', text } } ``` 出处:`$PKG/dsh-commands/lib/typert.remote-client.d.ts`(Remote 面)、`$PKG/dsh-commands/lib/types/types.d.ts:33-53`(结果类型)。 性质(都已核实): - `execute` 解析 `/name rawInput`,未注册的名字返回 `undefined`;**不经过模型**。 - 只写两条 log-only 事件(`command/run`、`command/done`),不包成回合、不触发模型请求(`$PKG/dsh-commands/lib/types/index.d.ts:114-141`)。 - `text` 是任意字符串,因此是天然的 JSON 载荷通道。 - `agentId` 是**被寻址的会话**;命令在目标 agent 的作用域里执行。 - 命令定义用 `ctx.commands.register({ name, description, recordInput?, handler })` 注册;普通 Context 上注册是全局的,所有 agent 可见。→ 用带命名空间的命令名把我们内部用的命令与用户命令区分开(`aux-chat`、`aux-end`),并在 `description` 里写明内部用途。 **命令执行是记录在目标会话日志里的**,所以: - 创建命令发到**主会话** → 主会话日志多两条 `command/run|done`(可接受,是官方行为,且让"何时开的辅助会话"可审计); - 销毁命令发到**辅助会话** → 生命周期记账落在辅助会话自己的日志里(反正马上要被删掉)。 ## 冻结的命令线格式 命令名不带斜杠;`line` = `/ `。`rawInput` 是**一行 JSON**(不含换行)。 ### 1. `aux-init` — 初始化(或复接)辅助会话 ``` /aux-init {"sessionId":"session-","mirrorOf":"session-","mode":"create|reclaim"(可选),"requestId":""(可选)} ``` - **v3 创建通道**:客户端先 `sessions.fork({sessionId: 主会话, atSeq: 0})`——服务端铸造子 id、子 header 持久携带 `parentSession=主会话`(结构性归属)、**空继承**(无可画事件)、自动 attach 父工作区、父日志零写入;再把 `/aux-init` 发到**该子会话自己**(`agentId` = 子 id)。历史兼容:对无 `parentSession` 的普通会话(旧 `sessions.create` 通道)init 仍然可用。 - `mirrorOf` 必填:主会话 id,**创建当帧捕获、此后冻结**(PRD v2.0 R01)。子 header 带 `parentSession` 时与其不符 → `aux-chat/parent-mismatch` 拒绝(服务端结构校验,不信任浏览器自报;身份类拒绝**不触发**半成品清理)。 - 成功载荷的 `downgrade` 为三值:`full` = 主会话已提交转录已由隐藏 `user/message`(`source.kind="aux-chat-mirror"`,`{surfaceOp:"append"}`,压缩检查点同款)注入子会话日志;`summary` = 注入失败,回退系统提示词摘要;`none` = `mirrorHistory:false`。 - 成功载荷另带 `inherited`(**实测交付**,面板据此显示,不再靠承诺):`{ form: 'full'|'summary'|'none', entries, chars, truncated, omitted }`。`form:'full'` 时若父会话长度超过 `mirrorMaxChars`,交付的是**最近连续尾窗**(`truncated:true`,`omitted` = 被排除的较早条目数),此时 `chars ≈ mirrorMaxChars`;只有最新一条都放不下或父会话无可继承记录时才回退 `summary`(`entries/chars` 为摘要的行数/字符数)。 - `mode:"reclaim"`(PRD v2.0 §7.1):客户端带着本窗口记录的 tab→child 绑定重连(页面刷新)。Host 侧语义: - 已登记 ⇒ 直接返回原 `info` + `reclaimed:true`,**绝不二次创建**;`mirrorOf` 不匹配 ⇒ `aux-chat/reclaim-mismatch` 拒绝。 - 未登记但 marker 在(插件重载恢复路径)⇒ 走完整重新初始化,返回 `reclaimed:true`。 - 未登记且无 marker(外来 id / Host 已重启回收)⇒ `aux-chat/session-not-created` 拒绝。 - `requestId` 可选,仅用于日志关联。 - Host 侧行为:校验非嵌套、非已初始化(reclaim 除外)→ 构建镜像计划 → 注册 agent 作用域提示词/工具守卫 → 权限/标题/工作区 → 写 marker。 - 成功:`{ kind:'success', text: JSON.stringify({ ok:true, info: AuxSessionInfo, warnings, downgrade, inherited, reclaimed? }) }` - `info.hostGeneration`:本次 Host 启动的代次(`gen-`,每次 `apply()` 铸造一次)。客户端随关闭请求回传。 - 失败:`{ kind:'error', text: ': <原因>' }`;失败后的半成品由 Host 就地清理(reclaim 失败除外——它从未创建任何东西)。 - `recordInput:false`(避免把 JSON 载荷抄进会话日志)。 ### 2. `aux-end` — 永久销毁辅助会话 ``` /aux-end {"sessionId":"session-","operationId":""(可选),"hostGeneration":"gen-"(可选)} ``` - 客户端把它发到**辅助会话自己**的 agent(`agentId` = 该辅助会话)。 - `operationId` 可选:关闭操作的幂等凭据。**重试固定同一 operationId**(PRD v2.0 §6.3/§9);成功时原样回显在结果里。 - `hostGeneration` 可选但强烈建议:**不匹配当前 Host 代次 ⇒ `aux-chat/stale-generation` 拒绝,且什么都不动**(T12:越过一个 Host 重启的旧请求不得作用于同 id 下的新资源)。客户端收到该错误即知资源已随旧 Host 结束。 - Host 侧行为:代次校验 → 登记/marker 归属校验 → `docs/VERIFIED-API.md` B5 四步销毁。 - 成功:`{ kind:'success', text: JSON.stringify({ ok:true, ...AuxDisposeResult, operationId? }) }` - 失败:`{ kind:'error', text: ': <原因>' }`。`aux-chat/not-registered` 表示"已销毁完毕或从来不是本插件的会话"——客户端将其归类为**已收敛**而非失败。 - `recordInput:false` ## 为什么辅助会话要归档,以及 `agent/pre-step` 门禁桥接 用户要求辅助会话"不作为 session 出现在侧边栏"。它刻意是一个**普通会话**(不设 `origin:'subagent'`——那会让官方 composer 被 `session/agent-busy` 栅栏拦死),而官方侧栏 `sessionVisible` 的隐藏轴只有 subagent-origin 与 **workspace 归档集**(默认过滤器与搜索均排除归档)。 代价:`@deepseek-ai/dsh-api-session-controller` 无条件组合 `ArchivedSessionGate`——归档会话的 `agent/pre-step` 返回 `{kind:'reject'}`,回合以 `blocked` 结束、不发模型请求。**桥接(`src/host/hide.ts`,默认开启)**:门禁每次 step 实时重读 `archivedSessionIds`,且它由更早 bundle 注册;本插件以 `prepend:true` 注册的监听器对 `owned` 集合内的会话先 `await unarchiveSession(id)`、再 `next()`——门禁读到集合已无该 id 而放行,**整条瀑布(含 base 运行期上下文与其余 17 个监听器)照常运行**;回合结束(`agent/status` → `idle`)后重新归档。旧版"自己应答 step"的短路已删除(那会丢全部注入)。 > 风险披露:桥接依赖"瀑布先注册者先跑 + `prepend` 抢先 + 门禁实时读集合"三点语义。若将来 DSH 改变瀑布顺序或门禁改为缓存读取,桥接会失效(表现为归档辅助会话发消息无反应),退路是 `hideFromSidebar:false`(不归档、不装桥接,行为回到行可见)。 ### 不 seed 的上下文继承 辅助会话**完全不 seed**。原因:主机会把整条日志(自 seq 0 起,含继承段)发给客户端,而浏览器线上的 `SessionWireHeader` **没有** `inheritedEventCount`/`isSeeded`,客户端四个相关包里这两个词出现次数均为 0 —— 平台没有给客户端区分"继承段"的任何信息,seed 一旦使用,主会话内容必然被画成气泡。所以继承改为**只走系统提示词 section**(`aux-chat-mirror`),模型依旧知道主会话在做什么,而 transcript 保持干净。 ## 客户端调用形态 ```ts // 创建(v3:fork 空继承拿结构性 parentSession,子 id 服务端铸造) const auxId = await ctx.sessions.fork({ sessionId: mainSessionId, atSeq: 0 }) const res = await ctx.remote.commands.execute( auxId, `/aux-init ${JSON.stringify({ sessionId: auxId, mirrorOf: mainSessionId })}`, [], ) if (!res.ok) throw res.error const exec = res.value if (exec === undefined) throw new Error('aux-chat command is not registered') if (exec.result.kind === 'error') throw new Error(exec.result.text) const payload = JSON.parse(exec.result.text ?? '{}') // { ok, info, warnings, downgrade, inherited } // 刷新重连(发给被复接的辅助会话自己;成功即拿到原 child 的 info) await ctx.remote.commands.execute( auxId, `/aux-init ${JSON.stringify({ sessionId: auxId, mirrorOf: mainSessionId, mode: 'reclaim' })}`, [], ) // 销毁(发给辅助会话自己的 agent;携带关闭绑定,重试固定同一 operationId) await ctx.remote.commands.execute( auxId, `/aux-end ${JSON.stringify({ sessionId: auxId, operationId, hostGeneration })}`, [], ) ``` **客户端不需要 `list`**:辅助会话的登记表以 root 级 store 保存在浏览器内存里(这正是"临时"的语义),主机侧另有标记文件只用于崩溃清扫。 ## 失败与降级 | 情况 | 表现 | 处理 | |---|---|---| | 命令未注册(插件 host 半侧未加载) | `execute` 返回 `undefined` | 面板显示"辅助会话服务不可用",不崩 | | `RemoteResult.ok === false` | 载体/业务错误 | 面板显示错误文本 | | `result.kind === 'error'` | 业务拒绝(如 mirrorOf 不存在) | 面板显示文本 | | `text` 非法 JSON | 解析失败 | 面板显示"返回载荷无法解析"并保留原始文本 | ## 未采用但可行的替代(记录在案,不在本期实现) 1. **手写 Typert contribution**(`ctx.typert.register` + 客户端 `ctx.remote.$mount`):需要复刻生成器产出的 descriptor/codec 结构,当前无公开文档,成本与风险都高于命令通道。 2. **包装 `sessionController.list`** 过滤辅助会话:DSH 无删除 API,且列表消费方众多,侵入性过高。