dsh 插件开发学习

profile 与插件包结构

本文是知识卡,不用顺序读。装插件报错、想知道某个文件能不能手改、或者要弄清配置顺序时来查。

DSH 版本:0.2.0-rc.2

~/.dsh/profiles/web 是 web profile 的全部配置所在。本文按文件逐个说明:它是什么、由谁维护、什么情况下需要改、以及它们如何组合成一棵 Cordis 插件树。

目录总览

~/.dsh/profiles/
├── node_modules/          # 解析拦截层的位置:0.2.0 由 launcher 在内存里应答,不落盘
├── dsh-tui/               # 另一个 profile(终端界面)
└── web/                   # ← 本篇主角:网页版 profile
    ├── package.json       # 我是谁 + 我要装哪些插件     ← 手写
    ├── cordis.yml         # 插件树根(空数组,每次启动重写)← 别动
    ├── cordis.patch.yml   # 我的覆盖层(空数组)        ← 手写
    ├── pnpm-workspace.yaml# 包管理器策略                ← 手写
    ├── pnpm-lock.yaml     # 依赖锁定快照                ← 生成
    └── node_modules/      # 已安装的依赖                ← 生成

一句话概括:这个目录本身几乎没有业务代码,它全部的职责是”声明配置清单”。真正的功能都在 node_modules 里的插件包中,由 DSH 启动时按顺序配置成一棵 Cordis 插件树。

逐文件说明

package.json:配置清单

{
  "name": "dsh-profile-web",
  "private": true,
  "dependencies": {
    "dsh-better-sidebar": "^0.19.0",
    "<插件名>": "link:/绝对路径/到/插件目录"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-better-sidebar",
        "<某个本地开发的插件>"
      ]
    }
  }
}

四个字段,四层含义:

  • name:profile 的名字,出现在日志里。注意它是 dsh-profile-web,而启动时要写 --profile web,两者不是同一个字符串。
  • private: true:防呆,避免误发到 npm。
  • dependencies:物理上装哪些包。这里有两种来源,要区分开:
    • "dsh-better-sidebar": "^0.19.0" —— 从 npm 仓库拉下来的第三方插件;
    • "link:..." —— 链到你自己的本地源码目录(所以改完插件源码,通常要重装或重启才生效)。
  • dsh.profile.bundles:逻辑上按什么顺序配置。数组顺序 = patch 叠加顺序:先 dsh-base(内核:配置、生命周期、Agent 循环),再 dsh-web-app(HTTP 服务、前端静态资源、Web 会话外壳),然后才是业务插件。顺序很重要,后面讲守卫时会看到为什么。每装一个新插件,这个数组就长一项。

小技巧:加插件优先用 dsh plugin --profile web add <插件来源>,而不是手改这个文件。CLI 会同时维护 dependencies 和 bundles 两边;手工只改一边,就会出现”装了但没挂载”或”声明了但找不到包”。

cordis.yml:插件树根

# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]

它是”第 0 层”:一个空的插件行列表。所有插件都是通过 patch 一层层”贴”到这个空列表上的,而不是直接写在这里。文件里的注释已经把规则写明了,照做即可 —— 不要动这个文件。它每次启动都会被 launcher 重写成上面这份空根:loader 需要磁盘上真有一个文件来锚定 profile 目录,而 loader 的写回(某个插件自行卸载时会把当前树落盘)可能把组合出来的行烙进这份文件,留着就会在下次启动时把各层的 insert 再叠一遍。

cordis.patch.yml:profile 覆盖层

当前是空的 [],但它是 profile 里最该由你动的文件。完整的合成顺序是:

空根(cordis.yml,每次启动改写)
  ├─ @deepseek-ai/dsh-base       的 cordis.patch.yml
  ├─ @deepseek-ai/dsh-web-app    的 cordis.patch.yml
  ├─ dsh-better-sidebar          的 cordis.patch.yml
  ├─ 其他插件                     的 cordis.patch.yml
  ├─ ← 本文件(profile 自己的补丁)
  ├─ ~/.dsh/cordis.patch.yml     (home 层,对所有 profile 生效,压在 profile 层之上)
  └─ 命令行 --patch 覆盖(按 argv 顺序)

~/.dsh/cordis.patch.yml 是机器级偏好,比 profile 自己的补丁更靠后,所以它能盖掉任何一个 profile 写的东西。启动器还会在这之后按环境追加补丁(例如 DSH_TELEMETRY_DISABLED 非空时关掉遥测那一行)。

想给某个插件加参数、临时关掉某一行、或者插一条自己的 entry,写这里。

当所有插件的特性patch后,我们还可以在这里最后对已经挂载的所有插件特性进行最后的修改。

比如换掉侧边栏内置终端的 shell:

- insert:
    - id: better-sidebar
      name: 'dsh-better-sidebar'
      config:
        shell: /bin/zsh
        shellArgs:
          - --noprofile
          - --no-rc

⚠️ 一个常见的坑:profile 自己的 cordis.patch.yml 是”人和工具共写”的。插件页(或 plugin_manager 工具)开关某个插件时改的就是这个文件——只改最后一处匹配覆盖的 disabled,一处都不匹配就追加一条;装新包时它改的是 package.json 的 bundles。所以手写的覆盖可能被工具再加一条,两处叠在一起,最后生效的是靠后的那条。也正因如此,插件作者现在更推荐把挂载声明写进插件包自带的 cordis.patch.yml(见下文),装包时自动启用。如果两边都写了 insert,同一个插件会被挂载两次 —— 路由注册两遍,其中一份必然在竞争里失败,整棵树可能起不来。

pnpm-workspace.yaml:包管理器策略

packages:
  - .
nodeLinker: hoisted
autoInstallPeers: false
minimumReleaseAgeExclude:
  - dsh-better-sidebar@0.13.0
  - '@noob-stupid/dsh-plugin-console@0.3.15'
allowBuilds:
  node-pty: true
  protobufjs: true

五条设置各有用途:

配置 作用 为什么这么设
packages: [.] 单包工作区 profile 自己就是一个包
nodeLinker: hoisted 依赖平铺到顶层 node_modules 插件之间要互相 import,扁平布局才好解析(pnpm 默认的隔离布局会让插件找不到 peer)
autoInstallPeers: false 不自动补 peer 依赖 避免 DSH 内核包被重复装一份,导致”两个实例”类诡异 bug
minimumReleaseAgeExclude 对指定包跳过”新版本冷静期” 刚发布的版本想立刻用
allowBuilds 只放行这两个包的安装脚本 pnpm 默认拦截依赖的 postinstall;node-pty 要编译原生模块(终端功能),protobufjs 要生成代码

装插件时报 “Ignored build scripts”,八成就是这里没放行。0.2.0 的插件页会把被 pnpm 拦下的包名列成待决定项(pnpm-workspace.yaml 里出现 set this to true or false 占位),点”允许这些脚本并重试”就会把它写成 true。

pnpm-lock.yaml:依赖锁定快照

44KB,记录整棵依赖树的精确版本 + 完整性哈希,保证”今天装的和明天装的是同一套东西”。由 pnpm install 自动维护,不要手改。

node_modules/:依赖安装目录

186 个顶层条目,但形态分两类,看懂了两类,排查问题会快很多:

形态 例子 含义
真实目录 dsh-better-sidebar/ 从 npm 装下来的包,源码 + lib/ 编译产物都在里面(nodeLinker: hoisted 会把依赖平铺上来)
指向本地源码的软链 <插件名> -> ../../../../<你的源码目录>/<插件目录> 用 link: 装的插件,改源码直接作用于这个路径

另外两个子目录:

  • .pnpm/lock.yaml:pnpm 自己的虚拟存储元数据(注意它和根目录的 pnpm-lock.yaml 是两份不同的文件);
  • .bin/:随依赖带进来的命令行工具(20 个),比如 katex、marked、modlens —— 这些不是给你用的业务命令,是插件构建时调用的。

.dsh-module-fallback/:0.1.5 的遗留目录

0.1.5 的 link 后端会把组合包依赖的包投影到 profile 目录里,落成 .dsh-module-fallback/node_modules/;那时 node_modules 顶层很多条目不是真实目录,而是指回它的软链。

0.2.0 不再落盘。launcher 读 profile 时算出一张内存里的包表:安装锚点(dsh 自己那套包,含 cordis)的依赖闭包 + profile 已装的依赖,再加上 profile node_modules 里指向 profile 之外真实目录的软链(也就是 link: 装的那些插件)。然后由这张表应答 Node 的 ESM/CJS 解析,拦截层就是 profile 目录上一层的 node_modules 位置:

mermaid 图表

三个要点:

  1. .dsh-module-fallback/ 是旧版本的残留。0.2.0 启动读 profile 时会把它连同 node_modules 里所有指进它的软链一起清掉(loadProfile 里的 removeLinkProjections)。看到它,说明这个 profile 被 0.1.5 启动过。
  2. 它不会再被重建,所以”断了就 pnpm install“这条老经验对它不适用:它该消失,指进它的断链会被启动时一并清掉。
  3. 解析能命中的只有两类东西——安装锚点的依赖闭包和 profile 已装的依赖。node_modules 里除这两类之外的残留目录都不会参与解析。

插件包内部结构

profile 只是清单,插件的”实体”在 node_modules/<插件名>。以侧边栏插件 dsh-better-sidebar@0.19.0 为例:

dsh-better-sidebar/
├── package.json        # 关键在 dsh 字段(见下)
├── cordis.patch.yml    # 自带的挂载声明(文件名由 dsh.bundle.patch 声明,见下)
├── lib/                # 编译产物:index.js(宿主)+ client.js(浏览器端)
├── src/                # TypeScript 源码(client/ 下是 React 组件)
├── scripts/            # install.sh / install.ps1 安装辅助
└── README.md           # 中文说明 94KB,功能文档很全

它的 package.json 里有 DSH 专属的 dsh 字段,这是插件能被”自动配置”的秘密:

"dsh": {
  "bundle": { "patch": "./cordis.patch.yml" },
  "client": {
    "inject": ["@deepseek-ai/dsh-client-ui-slots", "..."],
    "platform": "web"
  }
}
  • bundle.patch:CLI 读到这个声明,就把包名追加进 dsh.profile.bundles;启动时把这份 patch 合进插件树 —— 一条命令完成”安装 + 挂载”,不用改 profile 任何文件。

这个字段决定文件名,文件名本身没有约定。 内核里写死的只有 profile 目录和 ~/.dsh/ 下的 cordis.patch.yml;插件包自带的补丁叫什么由 bundle.patch 指,可以是一个路径,也可以是有序数组(一个包多份补丁按序应用)。所以官方模板里叫 cordis.patch.yml、02-最小插件的结构与配置的示例叫 patch.yaml,两者身份完全相同 —— 都是被声明的 bundle 层,不是两种格式。

  • client.platform / client.inject:告诉宿主”这个包有浏览器端代码,需要注入哪些前端依赖(slot、locale 等)”。

再看它自带的 cordis.patch.yml,里面藏了一个很精巧的设计:

- insert:
    - id: better-sidebar
      name: 'dsh-better-sidebar'
      disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"

!!js 后面是一段在加载时求值的表达式:如果已经有别的 entry(比如某个聚合包)挂了同一个包,这一行就自动禁用自己。因为重复挂载会让 /sidebar/api 注册两次,直接 “duplicate prefix route” 把整棵树搞崩。这也解释了前面那句顺序要求:守卫只能看到排在它前面的行,所以聚合包必须排在它前面 —— 而 dsh plugin add 正好是往后追加,天然满足。

mermaid 图表

任何 DSH 插件都是这个结构:package.json 里声明 dsh.bundle.patch,自带的 cordis.patch.yml 里写一行 insert: { id: ..., name: '<插件名>' } 把自己挂上树;宿主端代码(lib/index.js)提供 HTTP 路由,客户端代码(lib/client.js)注册界面上的标签页或查看器。你自己写的插件只要照这个骨架填内容就能被自动配置。

dsh-tui profile 对照

// profiles/dsh-tui/package.json
{
  "name": "dsh-profile-dsh-tui",
  "private": true,
  "dependencies": { "@deepseek-harness-tui/dsh-tui": "0.10.1" },
  "dsh": {
    "profile": {
      "bundles": ["@deepseek-ai/dsh-base", "@deepseek-harness-tui/dsh-tui"],
      "patchReload": "live"
    }
  }
}

结构一模一样(dependencies + bundles + 那五个文件),差别只在内容:界面包从 dsh-web-app 换成 TUI 包。

上面这份 package.json 里的 patchReload 是旧版本 dsh 写的字段,新版已经不读它了(写什么都当没写,也不会报错)。现在的热重载开关在插件树里的 hmr 那行,见下面速查。

这也说明 profile 的设计意图就是”一套内核,多套界面/插件集合,互不干扰“。

热重载速查

热重载由 base 组合包里 id 为 hmr 的那行负责,插件是 @deepseek-ai/dsh-hmr,配置和源码两部分共用一个队列:

# Profile configuration reloads by default; module roots are opt-in.
- id: hmr
  name: '@deepseek-ai/dsh-hmr'
  disabled: !!js "!ctx.get('profileContext')"
  config:
    root: []
部分 默认 怎么开
配置热重载 开 盯 profile 的 cordis.patch.yml、~/.dsh/cordis.patch.yml 和 profile 的 package.json,变化就重新组合配置
源码热重载 关(root: []) 在 profile patch 里给这行配 root,列出要监视的目录
  • disabled 是条表达式:dsh 启动的 profile 有 profileContext,所以启用;嵌入式宿主没有这个上下文就关掉。
  • headless、sdk、acp 组合包在各自 patch 里把这行 disabled: true;要在那边用,在自己的 profile patch 里写 disabled: false。
  • ignored 默认 **/node_modules、**/.*、cache、data;debounce 默认 100ms;配置监听还用 Chokidar 的写入稳定窗口(默认 2 秒)。
  • 旧 profile 里残留的 dsh.profile.patchReload(live / startup)新版不再读,写了也不报错。
  • 哪些改动接不住、改了没生效怎么查,见07-配置与源码的热重载。

文件维护速查表

文件/目录 手改? 说明
package.json ✅ 可以 但优先用 dsh plugin add
cordis.patch.yml ✅ 推荐 覆盖配置、临时禁用、自定义 entry 都写这里
pnpm-workspace.yaml ✅ 可以 装插件报错时才需要动
cordis.yml ❌ 别动 空根;每次启动都被 launcher 重写,手改的内容会丢
pnpm-lock.yaml / .pnpm/lock.yaml ❌ 别动 由 pnpm 维护
node_modules/ ❌ 别动 由 pnpm 维护;软链断了就 pnpm install
.dsh-module-fallback/ ❌ 别动 0.1.5 的遗留目录;下次启动会被自动清掉

常用命令

cd ~/.dsh/profiles/web

# 1. 依赖装好没 / 软链断没断
pnpm install

# 2. 谁挂了哪个插件(看 bundle 顺序)
node -e "console.log(require('./package.json').dsh.profile.bundles)"

# 3. 启动 web profile,观察插件树配置日志
dsh web          # 等价于 dsh --profile web

# 4. 某个包到底是真目录还是软链
ls -la node_modules/dsh-better-sidebar node_modules/.bin | head

要点

  • profiles/web = 配置清单,不是代码仓库:三个手写文件(package.json、cordis.patch.yml、pnpm-workspace.yaml)+ launcher 每次重写的空根 cordis.yml + 两个生成物(pnpm-lock.yaml、node_modules)。
  • 合成模型是”空根 + 多层 patch“:bundles 里每个插件被 dsh.bundle.patch 声明的那份补丁文件(例子里叫 cordis.patch.yml,02-最小插件的结构与配置的例子叫 patch.yaml)→ profile 自己的 cordis.patch.yml → ~/.dsh/cordis.patch.yml → 命令行 --patch。
  • dsh 字段(bundle.patch / client.platform)让插件能”装完即挂载”,这是官方推荐的插件分发姿势;手写在 profile 里的挂载行会和插件自带的 insert 打架,重复挂载同一行。
  • 插件源码不在 profile 目录里也能解析依赖,靠的是 launcher 启动时的那张内存包表(拦截层在 profile 目录上一层的 node_modules 位置),不是磁盘上的 .dsh-module-fallback;后者是 0.1.5 的遗留物,会被启动时清掉。