# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体 Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 > 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。 ## Problem 需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: - 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留) - 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一约定事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 ## Decision ### 分层 目录按照如下分层: - `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 - `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。 - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) │ consume ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) harness core packages ──────────────────┘ (types reach the browser via import type) ``` 方向纪律(每条都由包 deps 可核): - `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 - client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 - client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。 TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 #### 分层角色 | 层 | 包 | 职责 | 关键纪律 | |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | | 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | | 应用 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每个应用一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 各应用使用动态 import,因此不会互相加载;dist 定位等 workspace 知识留在 app | #### 命名规则 `packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。 #### 怎么接入一个新应用(操作清单) 1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 以下各节是前置层(`dsh-host-apiproxy`)承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC)时四象限不变: ``` client 发起 server 发起 request ① ClientRequest ③ ServerRequest (POST /api/ body) (WebSocket message:session 事件、审批/问答 requested) response ② ServerResponse ④ ClientResponse (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) ``` ### wire 全形:四具名判别 union(`api/rpc.ts`) | 类型 | 判别 tag | 字段 | rpcId 归属 | Web 承载 | |---|---|---|---|---| | `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | | `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | | `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | WebSocket text message | | `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | `RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 **rpcId 纪律**(`RpcId` 是 branded string,构造函数 `RpcId()`): - 谁发起谁 mint;应答一律回填对应 request 的 rpcId,**绝不 mint 新 id**。 - server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。 - 业务代码不 mint:unary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。 ### 签名窄形与载体补全 域接口签名只感知窄形:`RpcRequest

= { rpcId, payload }`、`RpcResponse = { rpcId, result: RpcResult }`。载体层把窄形补全为全形(补 `type` tag 与 `method`),方向不靠通道推断。`RpcResult = { ok: true; value } | { ok: false; error: RpcError }`——方法不 throw 业务错误。 ### RpcReceipt:载体回执 `ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛点是 `*/resolved` 帧。 ## 类型体系:函数签名即事实源 ### RpcMethodMap 与派生泛型(`api/rpc-map.ts`) 方法的参数/返回结构**只住在接口方法签名里**;map 登记方法本身;其余一切位置(handler、client、store、测试)引用派生泛型,禁止复写字面量或另起平铺具名类型: ```ts ignore-check export interface RpcMethodMap { 'session.list': SessionsApi['list'] // map key 即 wire 路径段 // …其余方法同形登记,全集见 api/rpc-map.ts } // 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束) export type RequestPayload = Parameters[0]['payload'] export type ResponseValue = Awaited> extends RpcResponse ? T : never ``` 流方法(`events.mux`/`events.host`)不进 map(不是 unary);`respond` 不进 map(是 client-response 不是方法调用)。 ### 错误模型(`RpcErrorDetailsMap`) 错误码示例一行: | code | details | 何时 | |---|---|---| | `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod 校验失败 | 码全集见 `api/rpc.ts` 的 `RpcErrorDetailsMap`。`RpcError` 是 map 展开的分布式 union:`code` 判别、`switch` 后 `details` 自动窄化;**details 必填**——新码=map 加一行+错误 schema 加一支,漏填是编译错误。transport 故障(断网、host 没起)由载体抛异常,与业务错误两层不混。 ### zod 双向校验与锚定 - **两级 parse**:全形 schema 一次(type/rpcId/method 结构 + handler 校验 path==method)→ 业务 payload 按 method/帧型分派二次 parse;拒收 = `bad-request`。 - **锚定**:schema 统一 `satisfies z.ZodType>`(`api/rpc.schema.ts`)。`Wire` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`,直接锚原类型全线不可用;JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。 - brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。 ## 约定面(ApiProxy) 根接口 `ApiProxy = { sessions, host, events, respond }`(`api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`)+ 根接口一个字段 + map 加行。 ### unary 方法表 方法示例一行(表结构即读法): | method key | 请求 payload | 返回 value | 语义 | |---|---|---|---| | `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 | 其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 ### 帧(server→client,具名 union) 两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| | `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传:core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 | 其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。 **透传纪律**:wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO;类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensible:client 对未知 type documented-default(忽略),事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。 ### 会话语义(impl 侧承诺) - **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。 - **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。 - **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。 - **冷会话处理遵循所有权**:`session.history` 与 `session.fork` 的源端读取会在不获取 Agent 的情况下检查持久化存储,而绑定到 Agent 的普通会话方法(如 `prompt`)则通过在途表去重后恢复会话。由会话支撑的 subagent 会拒绝这条通用恢复路径,且附加状态不对客户端暴露(`running` 已经覆盖)。 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:约定与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 - **预留方法纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。) ## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`) **协议不变量住基类,平台差异是两个切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。 ### IApiClient:caller 视图 与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名约定,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。 ### 基类持有的协议路径 | 路径 | 内容 | |---|---| | `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 | | `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` | | `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse | | unary 时限 | 普通 unary 调用使用 `AbortSignal.timeout`(默认 30s,构造参数可调);由用户掌控节奏的 `host.pickDirectory` 和 `command.execute` 不设该时限,但保留调用方/连接取消;流不设时限 | | `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority | ### 实例级 envelope 观测切面 四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。 ### 子类表(传输承载) | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | ## 怎么扩展(操作清单) **加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构笔记);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。 **加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。 **加一个错误码(2 步)**:①`RpcErrorDetailsMap` 加一行(details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。 **接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture)再覆写 `callUnary`/`openMux`/`openHost` 虚方法。约定与基类零改。 **升格一个预留方法**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。 ## Consequences 所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 ## Alternatives considered | 放弃项 | 一句话理由 | |---|---| | 按产品分包(web 一族、electron 一族) | 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力支持方分层让新应用零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | | 消费型 client 直连 ctx(省 apiproxy 一层) | client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | | 三信封模型(Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 | | 具名 Request/Response 类型对为事实源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 | | REST 风格路径 | 消费者是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 | | DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 | | cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 | | createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 | | 对 `command.execute` 应用 30 秒传输时限 | 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径 |