The host's own catalog puts an "open beside" action on every non-current
child row; this port had never wired it. Add it: the row grows a hover
button that opens the child's chat as a `subagentchat` resource in the
native right Sidebar, so a user can watch a child work without giving up
the pane he is in (the in-pane jump stays the plain row click).
- src/client/native/subagent-chat.ts: the resource-address codec, copied
from @deepseek-ai/dsh-client-ui-subagent rather than imported (the
package exports no helpers and the plugin must not reach into it).
- native/surface.ts: carry `kind` / `preferNewPane` on an open.
- SubagentView: `openChildAside` via `ctx.get?.('sidebarRight')` — an
optional probe, since a fake ctx without `get` otherwise crashes the
whole render (9 specs caught it) — threaded through all three
CatalogRows call sites.
- locales: `subagentOpenAside` in zh/en plus all 20 third-language
dictionaries (tests/locales.spec.ts enforces key-set parity).
- docs: the conventions page now documents the resource-open recipe.
Gates: typecheck / lint / build / test 132 files 1390 passed /
check:consumer-types, plus the repo's runtime-contract symbol check.
137 lines
12 KiB
Markdown
137 lines
12 KiB
Markdown
# DSH 0.2.0-rc.2 插件契约缺口反馈(dsh-apes-sidebar)
|
||
|
||
> 反馈人:Hermes@mac13
|
||
> 日期:2026-10-13
|
||
> 对象:figmar(DSH 宿主团队)
|
||
> 来源:dsh-apes-sidebar feat/apes-tab-revival 分支适配 0.2.0-rc.2 时发现的四个契约缺口
|
||
|
||
---
|
||
|
||
## 背景
|
||
|
||
dsh-apes-sidebar(better-sidebar 的 fork)在适配 DSH 0.2.0-rc.2 时,遇到四个**宿主侧 API/契约缺口**,目前均以插件侧 workaround 绕过,但根因在宿主。其中**缺口 4 是「设置开关点了不生效」的真根因**,代价最大、也最隐蔽(静默失败、无任何报错)。以下逐项说明现象、根因、当前 workaround、以及期望宿主提供的正式契约。
|
||
|
||
---
|
||
|
||
## 缺口 1:settings 契约——移除 `settings.register()` 后,插件自定义 prefs schema 无注册通道
|
||
|
||
### 现象
|
||
- 0.2.0-rc.2 起 `settings.register()` 被移除,改由 loader 自动注册插件导出的 `Config` schema,ns = **profile 条目的 id**(不是包名;`dsh-apes-sidebar` 这个 ns 注册不上,报 `No configurable plugin entry "dsh-apes-sidebar"`)。
|
||
- `settings.update(ns, patch, expectedRevision)` **只放行 schema 里标了 `volatile` 的字段**(`dsh-settings` 的 `write()` → `validatePaths` → `isVolatilePath`,非 volatile 路径抛 `Config field "<path>" is not volatile`)。
|
||
- 插件的用户偏好(tabsEnabled / autoOpenSubagent 等)原本定义在独立的 `PrefsSchema`,而 **loader 只注册 `Config`**,导致 `PrefsSchema` 成为死代码、从未被宿主认识。
|
||
|
||
### 当前 workaround(插件侧)
|
||
- 把用户偏好字段**合并进 `Config`**(真正被 loader 注册的 schema),每个字段标 `.extra('volatile', true)`;部署字段(readLimit / shell 等)保持**非 volatile**。
|
||
- 条目 id 与 settings ns 对齐为 `better-sidebar`。
|
||
|
||
### 期望宿主提供的契约
|
||
1. **明确文档**:0.2.0-rc.2 起插件如何注册「用户偏好 schema」+ 如何声明字段 volatile(目前只能读 `dsh-settings` / `cosmokit` / `schemastery` 源码逆向)。
|
||
2. **ns 解析规则文档化**:settings ns 取的是 profile 条目 id 而非包名,这一条没有任何文档,是 `No configurable plugin entry` 的唯一原因。
|
||
3. **volatile 语义边界**:volatile 字段的约束("fixed object path without an enclosing volatile field")需文档化——尤其 `z.dict` 节点标 volatile 后,dict 内任意 key 是否都可写(实测可以,但无文档背书)。
|
||
|
||
---
|
||
|
||
## 缺口 2:`SidebarSessionsService` 缺 `openSubagent(address)` 方法
|
||
|
||
### 现象
|
||
- 子代理(subagent)拓扑图里,点击一个子代理行应切到子会话。
|
||
- better-sidebar 原版的 `openChild` 调用 `sessions.openSubagent(address)`(`address` 含 `childSessionId` + `parentSessionId`)。
|
||
- **DSH 0.2.0-rc.2 的 `SidebarSessionsService` 没有 `openSubagent` 方法**(`context-types.ts` 声明为可选 `openSubagent?`,但宿主 runtime 未实现/未暴露)。
|
||
- 结果:点击子代理行无反应("点不进去")。
|
||
|
||
### 当前 workaround(插件侧)
|
||
- `openChild` 降级:`sessions.openSubagent(address)` 优先;宿主未暴露时回退 `sessions.open(address.childSessionId)`。
|
||
- `sessions.open(id)` 语义是「选中一个已列会话为当前会话」,子会话在 `sessions.list` 里,选中即落地到子会话布局。
|
||
|
||
### 期望宿主提供的契约
|
||
1. **实现并暴露 `openSubagent(address)`**:比 `sessions.open(childSessionId)` 更精确(带 parent 上下文,可区分同 id 不同 parent 的子会话)。
|
||
2. **或明确文档**:若 `sessions.open(childSessionId)` 就是推荐路径,请在 `SidebarSessionsService` 的 JSDoc 注明,避免插件按 `openSubagent` 契约写代码后静默失效。
|
||
|
||
---
|
||
|
||
## 缺口 3:`subagentsByParent` 对 root 会话不 hydrate
|
||
|
||
### 现象
|
||
- 子代理 catalog(`subagentsByParent`)对 **root 会话不 hydrate**,`rootCatalog` 恒为 `undefined`。
|
||
- 宿主日志有 warning:`[agent-registry] agent/disposed listener threw: TypeError Cannot read properties of undefined (reading 'catch')`,疑打断 hydrate 流程。
|
||
- 结果:任务管理 / 子 Agent tab 的子代理全部显示「加载中…」,永不 hydrate。
|
||
|
||
### 当前 workaround(插件侧)
|
||
- 用 `sessions.list`(byId 镜像)+ `synthesizeCatalog(byId, parentId)` 按原版 `SidebarSubagentChildEntry` 契约**合成 catalog**(label 回退 `summary.displayTitle`,mode/activity/hasChildren 推断),递归补缺后再喂原版 `CatalogRows`。
|
||
- 已生效(行可显示),但**合成数据缺少宿主实时 `activity`/`hasChildren`**,不如宿主 hydrate 精确。
|
||
|
||
### 期望宿主提供的契约
|
||
1. **修复 `agent/disposed` listener 的 `catch` TypeError**:该异常疑打断 `subagentsByParent` 的 hydrate。
|
||
2. **或提供降级数据源**:提供 `subagents.listByParent(rootId)` 返回 summary 级数据,让插件能显示「已知子代理 + 基础元数据」而非恒「加载中」。
|
||
|
||
---
|
||
|
||
## 缺口 4(真根因):插件 Config 必须用 `@deepseek-ai/schemastery`,否则 volatile 写入被 loader **静默丢弃**
|
||
|
||
### 现象
|
||
- 设置页的每个开关点击后:**不报错、无 toast、但状态立刻弹回**,即「开关都不能开启或关闭」,且开关**对运行时毫无作用**。
|
||
- 服务端 `settings.update(ns, patch, revision)` **返回成功**,profile 文档 `cordis.patch.yml` **也确实被写入**(例如 `tabsEnabled.git: false` 落盘成功)。
|
||
- 但读回(`settings.describe().value`)与插件自身 `config` **永远不变**,且插件**既不被重启、也不被就地更新**。
|
||
|
||
### 根因链(逐层定位,全部有宿主源码/运行时证据)
|
||
1. 宿主 `dsh-settings` 的 `write()` 落盘后,靠 `configEditor.edit()` → `reconcileProfilePatches()` → 最终回到 `cordis-plugin-loader` 的条目 `update()`。
|
||
2. loader 判定 `volatileOnly`(本次改动只涉及 volatile 字段)后走 `_commitVolatile()`,其**第一行**是:
|
||
`const refs = volatileEntries(fiber.config); if (!refs.length) return true;`
|
||
3. `fiber.config` 里的 volatile 字段应当是 `createVolatile()` 产生的**引用对象**(形如 `Object.freeze({ get(), [Symbol.for('cosmokit.volatile.write')]() })`),而 `volatileEntries()` 只认这种引用。
|
||
4. 这些引用由 **`schemastery` 的 `Schema.resolve()` 在遇到 `meta.volatile` 节点时创建**(`@deepseek-ai/schemastery` 的 `resolve()` 有该分支;公版 `schemastery` **没有**)。
|
||
5. 宿主 `cordis` 的 `resolveConfig()` 走的是 **`runtime.Config["~standard"].validate()`** —— 也就是**插件自己那份 schemastery 的解析器**。
|
||
6. 若插件声明的是 **npm 公版 `schemastery`(3.18.0,`volatile` 相关代码 0 处)**,则解析结果里**没有任何 volatile 引用** → `refs.length === 0` → `_commitVolatile()` 立即 `return true` → loader 视作「已处理」→ **既不就地更新也不重启** → 写入被彻底丢弃。
|
||
7. 插件侧实测确认:修复前 `config.tabsEnabled` 的 `write` 符号为 `undefined`(`tabsIsVolatile=false`);改用宿主同源包后变为 `function`(`tabsIsVolatile=true`),同一套探针下 `revision` 由**恒为 1**变为**单调递增 0→6**,每次写入立即读回正确值。
|
||
|
||
### 当前 workaround(插件侧)
|
||
- `src/config.ts`:`import z from 'schemastery'` → `import z from '@deepseek-ai/schemastery'`。
|
||
- `package.json`:`dependencies.schemastery` 移除;`peerDependencies` 加 `"@deepseek-ai/schemastery": "^3.18.1"`(宿主在 mount 时提供,与本 repo `pnpm-workspace.yaml` 的既有约定一致);`devDependencies` 钉 `3.18.4` 供开发树解析。
|
||
- 已验证:`tabsIsVolatile=true`、`rev` 单调递增、写入即时读回、门禁全绿(typecheck/lint/build/test 131 files 1385 passed)。
|
||
|
||
### 期望宿主提供的契约
|
||
1. **文档化**:「声明 volatile 字段的插件必须从 `@deepseek-ai/schemastery` 导入 schemastery」——这是强制要求(宿主的 volatile 能力是 schemastery 的 fork 特性),但当前无任何文档或校验。
|
||
2. **启动期校验(强烈建议)**:loader 加载插件时,若插件 `Config` 含 `meta.volatile` 节点,但解析后 `fiber.config` 里**不存在任何 volatile 引用**,应**报错或 warn**。现状是「静默丢弃每一次用户设置写入」,排查成本极高(需读 4 个包的源码才能定位)。
|
||
3. **或消除版本耦合**:让 `resolveConfig` 使用宿主自己的 schemastery 解析器,而不是插件携带的那一份,从根上避免此类版本错配。
|
||
|
||
---
|
||
|
||
## 汇总
|
||
|
||
| # | 缺口 | 严重度 | 当前 workaround | 期望宿主动作 |
|
||
|---|------|--------|----------------|-------------|
|
||
| 1 | settings 契约(ns = 条目 id、volatile 无文档) | 中(已绕过) | 偏好合并进 `Config` + `.extra('volatile',true)` + ns 对齐条目 id | 文档化 ns 解析规则与 volatile 声明 |
|
||
| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 文档化推荐路径(官方路径见下方补充) |
|
||
| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 + 改用 `sessions.refreshProjections` | 修 TypeError 或文档化 `refreshProjections` |
|
||
| 4 | **插件 Config 未用 `@deepseek-ai/schemastery` → volatile 写入被静默丢弃** | **最高(设置全部失效且无报错)** | 改用宿主同源 schemastery 包 | 文档化强制要求 + 启动期校验 + 解耦解析器 |
|
||
|
||
---
|
||
|
||
## 补充(2026-10-13 晚,从宿主自身代码学到的官方路径)
|
||
|
||
在上面三个替代 API 都 0 命中的同时,宿主**自己的**子代理 UI(`@deepseek-ai/dsh-client-ui-subagent/lib/client.js:945-1005`)给出了官方正确姿势:
|
||
|
||
```js
|
||
const catalogActions = (_parentSessionId) => ({
|
||
openChild(address) { ctx.uiWorkspace.openSession(address) }, // 传 address(多态 target)
|
||
openChildAside(address) { ctx.sidebarRight.openResource(subagentChatAddress(address),
|
||
{ kind: 'subagentchat', preferNewPane: true }) },
|
||
refreshProjection(id) { ctx.sessions.refreshProjections(id) }, // ← catalog 唯一 kick
|
||
})
|
||
```
|
||
|
||
链路:`uiWorkspace.openSession(target)` → `replaceMain(target)` → `sessions.retain(target)`,**target 接受 SessionId 或 SubagentAddress**,且**没有子会话守卫**。
|
||
|
||
⇒ **缺口 2/3 的正确诉求不是「补 API」,而是「文档化这两条官方路径」**:
|
||
① 切到子会话 = `sessions.open(childSessionId)`(等价于官方的 `openSession(target)`);
|
||
② 让宿主 hydrate 某个 parent 的 catalog = `sessions.refreshProjections(parentId)`(**唯一**的 kick,旧名 `refreshSubagents`/`setSubagentCatalogOpen` 已删除且不报错)。
|
||
另外官方还有一条能力:把子会话聊天作为原生右侧栏资源打开(`sidebarRight.openResource(..., {kind:'subagentchat', preferNewPane:true})`)——**本插件已按官方形式接入**(子代理行上的「在侧栏打开」按钮),同样建议文档化。
|
||
|
||
---
|
||
|
||
## 复现环境
|
||
- DSH Desktop 2.0.17(Electron),宿主内置 `@deepseek-ai/dsh = 0.2.0-rc.2`、`@deepseek-ai/schemastery = 3.18.4`
|
||
- dsh-apes-sidebar feat/apes-tab-revival(+ 本反馈对应的改动)
|
||
- macOS 13.7.8,Node v22.22.0
|
||
- 插件加载点:`~/.dsh/profiles/desktop/node_modules/dsh-apes-sidebar/lib/`
|
||
- 关键源码锚点:`@deepseek-ai/cordis/lib/index.js: resolveConfig()`(`~standard.validate`)、`@deepseek-ai/cordis-plugin-loader/lib/index.js: _commitVolatile()`、`@deepseek-ai/schemastery/lib/index.cjs: Schema.resolve()`、`@deepseek-ai/dsh-settings/lib/index.js: describe()/write()`
|