本文是知识卡,不用顺序读。装插件报错、想知道某个文件能不能手改、或者要弄清配置顺序时来查。
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 位置:
三个要点:
.dsh-module-fallback/是旧版本的残留。0.2.0 启动读 profile 时会把它连同node_modules里所有指进它的软链一起清掉(loadProfile里的removeLinkProjections)。看到它,说明这个 profile 被 0.1.5 启动过。- 它不会再被重建,所以”断了就
pnpm install“这条老经验对它不适用:它该消失,指进它的断链会被启动时一并清掉。 - 解析能命中的只有两类东西——安装锚点的依赖闭包和 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 正好是往后追加,天然满足。
任何 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 的遗留物,会被启动时清掉。