# DSH 插件机制调研
> 目的:为 `doc/策划.md` 的架构决策提供依据。所有结论都标注了本机出处,便于复核。
> 调研时间:2026-10-30。版本基线:npm 全局 `@deepseek-ai/dsh@0.1.5-rc.1`、桌面版 `DSH NEXT`(自带 asar 运行时)。
---
## 1. 结论摘要
1. **DSH 插件就是一个 npm 包**:`package.json` 里用 `dsh.bundle.patch` 指向一份 `cordis.patch.yml`,安装后被并入 profile 的 bundle 栈,随 profile 启动挂载。
2. **插件入口是 cordis 插件模块**:导出 `name` / `inject` / `Config` / `apply(ctx, config)`;工具通过 `defineTool` 注册到 `ctx.tools`。
3. **桌面版 GUI 加载的是 `~/.dsh/profiles/desktop`**,不是 CLI 默认 profile;装插件要往这个 profile 里挂。
4. **本地开发可以用绝对路径 / `file://` URL 直接挂**,不必先发布 npm。
5. **长生命周期进程不能用 `ctx.subprocess` 或 `ctx.jobs`**:两者的生命周期都绑在服务和 agent 作用域上,作用域释放即终止/取消。
6. **界面能做**:客户端插件可以往宿主已有的 slot 里注册 React 组件(已装插件 `dsh-better-sidebar` 就是这么在会话标题栏放按钮的),宿主侧用 `ctx.webServer.register` 提供 HTTP 接口给界面调。
---
## 2. 插件包结构
以已装的两个第三方插件为样本。
### 2.1 纯服务端插件:`minecraft-dev@0.10.1`
路径:`~/.dsh/profiles/desktop/node_modules/minecraft-dev`
```jsonc
// package.json 关键字段(节选)
{
"name": "minecraft-dev",
"type": "module",
"main": "skills.js",
"exports": { "./skills": "./skills.js", "./tools": "./tools.js", "./preset": "./preset.js" },
"dsh": { "plugin": true, "kind": "server", "bundle": { "patch": "./cordis.patch.yml" } }
}
```
```yaml
# cordis.patch.yml
- insert:
- id: minecraft-tools
name: minecraft-dev/tools
config:
gradleTimeoutMs: 600000
outputTailChars: 12000
```
要点:
- `name` 可以直接写成「包名/导出子路径」,不需要单独的文件路径。
- `config` 就是插件 `Config`(schemastery)的取值。
- 一个包可以挂多行,每行一个插件入口。
### 2.2 带 UI 的插件:`dsh-better-sidebar@0.28.1`
```jsonc
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"inject": ["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-modules"],
"platform": "web"
},
"manifestVersion": 1
}
```
这个包里能直接抄的三件事:
**a) 往界面挂组件**(`src/client/context-diagnostics-ui.tsx`、`src/client/sidebar/bottom-toggle.tsx`):
```js
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'dsh-better-sidebar:bottom-toggle',
order: 10,
registrant: 'dsh-better-sidebar',
}, () => ))
```
`slots.inject` 会等目标 slot 的声明上账(插件激活可能早于声明),声明撤了再重来时回调会重跑。组件签名是 `(props, context/entry)` 形态,具体以运行时类型为准。
**b) 宿主提供接口**(`src/index.ts`):
```js
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
handler: async (req, res) => { /* 读 body → 分发到方法表 → 写 JSON */ },
}), 'dsh-better-sidebar: /sidebar/api routes')
```
**c) 客户端怎么找宿主**(`src/client/host-route-url.ts` 注释):Electron 外壳的页面是 `dsh-app://app/`,外壳把非静态路径转发给宿主,所以客户端必须用**相对路径**;绝对 origin 会变成跨域且宿主不返回 CORS 头。
已核实的 slot 名:`conversation.session.header.utilities`、`conversation.session.header.corner`(单占用,内置侧栏占着)、`shell.overlay`、`sidebar.right.pane.tab`、`sidebar.right.pane.tab.title`、`settings.section`。
### 2.3 客户端产物的加载协议(从 `lib/client.js` 反读)
成品不是 ESM,而是自注册到模块表:
```js
window.__ModuleLoader__.load({
id: 'dsh-better-sidebar',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
// …rolldown 打包后的 CJS 体…
exports.apply = apply
exports.inject = inject
return module.exports
},
})
```
- 外部依赖(走 `require`):`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/dsh-client-ui-primitives`。
- CSS 不是独立文件:构建期内联,运行时 `document.createElement('style')` + `appendChild` 注入。
- 导出的是 cordis 插件形状:`inject` + `apply`。
- 结论:客户端构建链只要能产出一段 CJS 并包上这层 wrapper 就行,不需要框架级模板。
### 2.4 右侧栏的正式接口(`dsh-client-ui-sidebar-right` README)
不是 DOM hack,有服务:
```js
// 注册一种标签类型
ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })
// 注册该类型的正文;Body 内用 useTabInfo() 取 { sidebar, panel, tab }
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)
// 打开:会自动展开面板("the panel expands, because content the user cannot see is not opened")
ctx.sidebarRight.openTab(kind, options?)
ctx.sidebarRight.isExpanded() / toggleExpanded() / close(tabId) / active()
```
这决定了日志可以做成右侧栏的一个标签页,并且能做到"启动即打开"。
---
## 3. 插件入口契约
样本:`~/.dsh/profiles/desktop/node_modules/minecraft-dev/tools.js`
```js
// 节选:只保留入口契约相关的部分
import { defineTool } from '@deepseek-ai/dsh-tools'
import Schema from '@deepseek-ai/schemastery'
export const name = 'minecraft-tools'
export const inject = ['tools'] // 依赖的 service
export const Config = Schema.object({ gradleTimeoutMs: Schema.number().default(600_000) })
export function apply(ctx, config) {
ctx.tools.register(defineTool({
name: 'mc_gradle',
description: '...', // 模型看到的能力说明
parameters: { projectDir: { type: 'string', required: true }, ... },
output: { schema: {...}, render: (_args, value) => [{ type: 'text', text: ... }] },
execute(args, exec) { /* exec.signal, exec.agent */ },
presentCall: (args) => ({ card: 'terminal', title: ..., cwd: ... }),
}))
}
```
`@deepseek-ai/dsh-tools` 的 README 明确了参数 DSL、输出 schema、`presentCall`/`presentResult` 的用途,以及执行流水线(`tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`)。工具描述与 schema 会进系统提示,所以描述必须写清"什么时候用"。
---
## 4. 安装通道
| 通道 | 做法 | 适用 |
|---|---|---|
| CLI | `dsh plugin --profile add `(转发给 profile 目录里的 pnpm,并调和 `dsh.profile.bundles`) | 发布后 |
| profile patch 行 | 在 `~/.dsh/profiles//cordis.patch.yml` 加 `insert` | 改 profile 行为 |
| 用户级 patch | `~/.dsh/cordis.patch.yml`,对所有 profile 生效,**优先级高于** profile 自己的 patch 文件 | 全局开关 |
| 路径挂载 | `insert` 行的 `name` 可以写绝对路径、`file://` URL 或包名 | 本地开发迭代 |
桌面版的 profile 在 `~/.dsh/profiles/desktop`,其 `package.json` 的 `dsh.profile.bundles` 已包含 base、web-app、dshmarket、桥接、两个第三方 UI 插件与 `minecraft-dev`。
证据:host 进程命令行
```
"DSH NEXT.exe" --expose-internals "...\app.asar\lib\host.js" "...\app.asar" C:\Users\hualo\.dsh\profiles\desktop "...\runtime\x64\primary-runtime"
```
`--dump-config` 可以打印组合后的插件树,安装完排查"到底挂上没有"用它。
---
## 5. 可用的宿主能力
| Service | 提供什么 | 对本项目的用处 |
|---|---|---|
| `ctx.tools` | 注册模型可见工具 | 4 个工具(次要能力) |
| `ctx.webServer` | `register({ kind, path, handler })` 挂 HTTP 路由 | **界面的数据通道**(`/mcdev/api`) |
| `ctx.slots`(客户端上下文) | `inject(name, cb)` / `register(options, Component)` | **标题栏胶囊** |
| `ctx.systemPrompt` | `section()` / `variable()`,可加系统提示段 | 可选:注入"工作区有 N 个 MC 工程" |
| `ctx.workspaceRegistry` | 用户级工程记录(路径、标题、会话) | 不作为检测来源:它是"用户加进来的项目",不是"磁盘上的 MC 工程" |
| `ctx.subprocess` | 托管子进程、输出收集、进程树终止 | **不用于启动游戏**(见 6.1) |
| `ctx.jobs` | 后台任务注册、状态、完成通知 | **不用于启动游戏**(见 6.2) |
| `ctx.settings` / storage | 设置与持久化 | 状态落盘用它或直接写 JSON 文件 |
工作区根:工具执行时的 `exec.agent.session.header.cwd`(系统提示里的 `cwd` 变量就是这么取的)。
---
## 6. 两条否定结论
### 6.1 为什么不用 `ctx.subprocess`
`@deepseek-ai/dsh-subprocess` README:*"Disposal of the service terminates every still-running managed process and awaits its exit, so process lifetime survives consumer reloads."*
也就是说,插件的服务被 dispose(重载、profile 重组合、宿主退出)时,托管进程一律被杀。启动器的核心诉求恰好相反:游戏要能活在 DSH 会话之外。所以用 `node:child_process` 自己 spawn + `detached` + 自建 supervisor。
### 6.2 为什么不用 `ctx.jobs`
`@deepseek-ai/dsh-jobs` README:*"Owner and service disposal cancel live work"*,且状态模型是 `running → completed/killed/failed`,语义是"agent 派出去的后台任务"。
游戏实例不是任务:它可能跑几小时、可能被用户手动关窗、可能比会话活得久,还要能被下一个会话接手。用 jobs 会被会话生命周期绑死。
---
## 7. 呈现层
v0.2 之后界面是主场,工具卡片是次要的。
- **界面**:客户端插件(React)注入 `conversation.session.header.utilities` 等 slot,数据走 `/mcdev/api/*`。
- **工具卡片**:`presentCall` / `presentResult` 返回 `card` 结构(`terminal`、`generic` 等),Web 客户端据此渲染。纯函数约束:不能读时间、不能 IO,因为回放时也会调用。
- 两者共用同一份服务状态,不做两套逻辑。
---
## 8. 待实测项(M0 清单)
| # | 待验证 | 验证方式 |
|---|---|---|
| V1 | 本地路径挂载能挂上,宿主入口生效 | desktop profile 的 patch 加一行指向本地入口,重启后调工具或打路由 |
| V2 | 桌面版实际运行的 `@deepseek-ai/dsh-tools` / client 包版本 | 读 `app.asar` 里的 `node_modules/@deepseek-ai/*/package.json` |
| V3 | **客户端包能不能被桌面版加载**:`__ModuleLoader__` wrapper 是否被认 | 最小 `lib/client.js`(只注册一个按钮)+ `dsh.client.inject`,重启后看标题栏 |
| V3b | 右侧栏标签类型能注册并自动展开 | 注册一个空 `mcdev.logs` 标签,调用 `ctx.sidebarRight.openTab` |
| V4 | `--dump-config` 是否可用来确认挂载 | `node /lib/bin.js --profile desktop --dump-config` |
| V5 | 宿主重启后 PID 探活是否可靠(Windows) | 起假进程 → 重启 → 列实例 |
| V6 | 相对路径请求宿主是否通(Electron 外壳) | 客户端 `fetch('/mcdev/api/ping')`,看是否 200 |
| V7 | slot / tab body 拿到的 props 形状 | 打印一次 `useTabInfo()`,确认能拿到 `tab` 与本次 launch 的关联方式 |
V3 / V3b 是主界面方案的关键闸门:任一不通过就退回独立网页面板。
### 8.1 Gradle 任务探测的实测代价
在 `C:\Projects\Custom-Machinery-1.20.1` 上跑 `gradlew tasks --all`:
- 耗时 **6 分 37 秒**;
- 最终失败:`Could not find dev.latvian.mods:kubejs:2001.6.4-build.121`(工程自身的依赖缺失,与我们的命令无关)。
结论:**不把 Gradle 探测放在扫描路径上**。静态解析读 runs 块,探测只做用户手动触发的一次性验证,结果按 mtime 缓存,失败原样展示。
---
## 9. 参考路径
```
~/.dsh/profiles/desktop/ 桌面版 profile(改这里)
~/.dsh/profiles/desktop/package.json bundle 栈
~/.dsh/profiles/desktop/cordis.patch.yml profile 用户层
~/.dsh/cordis.patch.yml 全局用户层(当前挂着两个 MCP)
~/.dsh/profiles/desktop/node_modules/minecraft-dev/ 插件样本(服务端)
~/.dsh/profiles/desktop/node_modules/dsh-better-sidebar/ 插件样本(含 client:
src/client/context-diagnostics-ui.tsx 标题栏 slot
src/client/sidebar/bottom-toggle.tsx 按钮示例
src/client/host-route-url.ts 同源踩坑
src/index.ts 路由注册)
~/.dsh/profiles/web/node_modules/@tt-a1i/archify-dsh/ 最小 bundle 样本
~/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/
各服务端包的 README 就是 API 文档;
dsh-client-ui-sidebar-right 的 README 是右侧栏
tab 注册与 ctx.sidebarRight 的权威说明
/resources/app.asar 桌面版运行时,内含 313 个 @deepseek-ai 包
其中 client 侧:dsh-client-ui-slots / -conversation /
-primitives / -layout / -modules / -locale / -store
```
桌面版包里没有 README(只有编译产物),client 侧 API 的权威样本是 `dsh-better-sidebar` 的源码。