上一篇给出了 dsh 的定位:模型之外的运行时脚手架,一切皆插件。这一篇回答一个更实际的问题:当你执行 npx @deepseek-ai/dsh web 时,运行中的那棵插件树是怎么被组装出来的?我们把答案拆成两半:仓库里几百个包如何组织,以及这些包如何经 profile 与组合包叠成一棵树。读完本篇,你应该能指着任意一个包说出它属于哪一层、会被谁组装。本文基于 2026 年 10 月的 master 分支(commit 5badb15,版本 0.2.1-alpha.1)。
packages/ 一页地图
dsh 是一个 pnpm monorepo,工作区约定写在 pnpm-workspace.yaml:除了 vendor/*(Cordis 及其配套插件)与 apps/*(cli 与 desktop 等成品),packages/ 下的所有包按两级目录组织,即 packages/<组>/<包>。我们数了一下,当前 master 上共有 54 个包组、319 个包,每个包都以 @deepseek-ai/dsh-* 命名,且只属于一个组。组是理解这个仓库的钥匙:根目录的 packages/README.md 就是一张组表,每个组自己的 README 是该能力家族的权威包地图,新包必须归入既有组——这是仓库成文的组织约定。
按与插件树的关系,我们把 54 个组归成六类(篇幅所限每类只点代表):
- core 系(core、context、plan、todo 等):产品主干。
core/session提供仅追加的会话事件日志(ctx.sessions),core/agent-loop是默认的轮次驱动器(ctx.agentLoop),core/system-prompt组装提示词片段与工具 schema,core/tools是带把关执行流水线的工具注册表(ctx.tools),core/scope提供按 agent 划分作用域的原语。 - llm 系(llm、ptc-runtime):消息与流式词汇表,以及模型适配器的注册位——seam 的接口侧。
- 能力 seam 系(fs、shell、subprocess、sandbox、subagent、terminal、webhook、mcp 等):一项可替换能力的三要素——服务定义、提供方、消费方——往往各占一包。这一系是"替换一个提供方就替换一项能力"的落点,第 9 篇专门展开。
- web/client 系(web、host、client、interaction 等):浏览器应用的另一半。
client/ui-*是几十个前端插件,host系承载 Web 服务器与 RPC 网关;api组(gateway 及各 controller)是远程 BFF 组装。 - boot 系(boot):启动器的库。
app-boot负责 profile 装载与boot()驱动,cmdline把 argv 交给树,plugin-manager管 profile 的插件启停与 bundle 安装,hmr协调重载,config-editor经 Loader 持久化配置。第 3 篇的主角。 - bundle 系(bundle):组装单元,下一节的主角。只有 6 个包——
dsh-base、dsh-web-app、dsh-headless、dsh-sdk-app、dsh-acp-app、dsh-sdk-minimal——却决定了每种产品面长什么样。它们几乎不含业务逻辑,内容就是 Cordis 补丁文件,是"配置即组装"的集中体现。
加一组合乎直觉的提醒:包多不代表启动时都会加载。运行中的树只装 profile 声明的组合包插入的那些插件,其余包躺在安装目录里待命。
这个 monorepo 还有两条成文的组织约定,读源码前值得知道。其一,依赖方向有纪律:packages/README.md 写明,扩展插件只允许依赖 Service Definition(服务定义),不许依赖具体提供方——dsh-agent-loop 可替换的前提就是没人直接 import 它;组合包作为组装者,才被允许依赖主干插件。其二,稳定性分级:多数组是稳定 API,experimental/ 组不承诺稳定性与支持,test-support/ 和 util/ 的兼容期望更低。依赖图本身是生成的(docs/module-graph.md,由 pnpm run gen-module-graph 产出,CI 校验新鲜度),不是手画的。以 web profile 为例,合成后的树大致长这样(行 id 取自 packages/bundle/base/cordis.patch.yml 与 packages/bundle/web-app/cordis.patch.yml):
flowchart TD
ROOT["根 Context:由 cordis:include 从合成条目列表挂载"] --> BASE["dsh-base 层插入的行"]
BASE --> B1["agent:活跃 agent 注册表"]
BASE --> B2["agent-loop:默认轮次驱动器"]
BASE --> B3["session:仅追加会话日志"]
BASE --> B4["llm:适配器 seam"]
BASE --> B5["sandbox / approval / permission:执行世界与把关"]
BASE --> B6["hmr / plugin-manager:重载与插件管理"]
ROOT --> WEB["dsh-web-app 层插入的行"]
WEB --> W1["web-startup:解析 --host/--port 的普通插件"]
WEB --> W2["webserver:懒配置绑定端口"]
WEB --> W3["web-runtime:就绪信号与浏览器交付"]
WEB --> W4["connection:已认证的连接服务"]
USER["上层 patch 按 id 指向任意一行"] -.->|"整体替换 config 或 disabled"| BASE
USER -.-> WEB两行注释。其一,dsh-base 的补丁文件开头自述它是 "ONE insert over the empty profile root":base 以一条 insert 把整个核心倒进空列表,之后所有层都按 id 与这些行对话。其二,行序没有加载语义——激活由服务可用性驱动,agent-loop 排在前面不代表它先启动;条目间的真实次序由依赖决定,这正是 Cordis Fiber 的话题,第 4 篇展开。
profile 与 bundle:组装的两个概念
架构文档 docs/architecture.zh.md 说:运行中的 dsh 是一棵插件树,由启动时按序叠加的各层组合而成。参与叠加的只有两种东西,它们都在自己的 package.json 里用 dsh 字段声明身份。
组合包(bundle) 是 Cordis 配置项及其挂载代码的分发格式:一个 npm 包,用 dsh.bundle 字段指向自己的补丁文件。打开 packages/bundle/base/package.json:31,可以看到 {"bundle": {"patch": "./cordis.patch.yml"}};packages/bundle/web-app/package.json:42 则是一个数组,一次声明五个补丁文件(主文件加 presets 子目录里的四份)。
profile 是存放在 Harness home($DSH_HOME/profiles/<name>/)中的具名组装。它做三件事:在 package.json 的 dsh.profile.bundles 里列出自己叠放哪些组合包,存放自己安装的树外插件(dsh plugin 命令装的插件落在这里,由 plugin-manager 包管理,它直接改写 profile 的补丁与 bundles 列表),保存用户自己的 cordis.patch.yml。除五个模板外,Electron 桌面应用还拥有保留的 desktop profile——profile 机制不只是命令行的玩具,而是所有应用载体共用的组装语言。发行版自带的五个模板定义在 packages/boot/app-boot/src/profile.ts:179 的 PROFILE_TEMPLATES:web、headless、sdk、acp 都由 dsh-base 打底——base 提供模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测这些所有 profile 共享的东西——再各加一个应用组合包(dsh-web-app、dsh-headless、dsh-sdk-app、dsh-acp-app);sdk-minimal 是刻意的例外:它自带完整的显式配置树,不叠 dsh-base,作为 SDK 的最小载体,也便于观察"一份显式配置长什么样"。
bundle 从哪加载?profile.ts:706 的 resolveBundleDir 做双锚点解析:先在 dsh 安装目录(apps/cli/package.json 所在处)解析包名,未命中再去 profile 目录解析,两处都没有才算 bundle 未安装。这个顺序保证了"安装包优先",也允许 profile 目录里存放特定版本。版本兼容性检查不过的 bundle 不致命,只是记入 skippedBundles,启动时一次性打印,你永远不会因为一个 bundle 版本不对而看到莫名其妙的半棵树。profile 目录缺失时,loadProfile(profile.ts:781)按模板自动初始化——所以第一次运行 dsh web 就能起服务,不需要任何手工配置。
还有一个容易忽略但很关键的动作:profile.ts:441 的 createRuntimeResolution 在安装锚点上做一次 BFS(含 peerDependencies),算出 profile 运行时依赖的完整包表,供 Node 的 ESM/CJS resolver 拦截层使用。它的目的只有一个:保证树外插件(用户后来安装的插件)与树内插件共享安装目录里唯一一份 cordis。没有这个表,两个插件可能各自加载自己的 Cordis 副本,Context 体系就分裂了。组装不只是"选对行",还包括"让所有人落在同一个运行时上"。
分层叠加:五条补丁层与两条替换规则
组装的核心算法在 packages/boot/app-boot/src/profile-context.ts:63 的 readProfilePatches:它把五类来源的补丁全部拍平成一个数组,打在空的根条目列表 [] 上,顺序如下。
flowchart TD
subgraph ORDER["叠加顺序:越靠后优先级越高"]
direction TB
L1["① bundle 层:按 dsh.profile.bundles 顺序,每层的补丁文件依次"] --> L2["② profile 用户层:profile/cordis.patch.yml"]
L2 --> L3["③ home 层:$DSH_HOME/cordis.patch.yml(机器级偏好)"]
L3 --> L4["④ --patch 命令行 overlay,按 argv 顺序"]
L4 --> L5["⑤ 遥测开关:DSH_TELEMETRY_DISABLED 非空则禁用 otel 行"]
end
ROOT["空根条目列表 []"] --> ORDER
RULES["applyEntryPatches 两条规则:按 id 整体替换 config;insert 立即建索引"]
ORDER --> TREE["合成后的插件树条目列表"]这个数组交给 vendor/include/src/index.ts:57 的 applyEntryPatches(),补丁本身是一个 PatchOptions(:130):{id?, name?, config?, disabled?, inject?, intercept?, isolate?, insert?}。算法只有两条规则,都值得逐字理解。
规则一:非 insert 的 patch 按 id 定位目标行,用其余字段整体替换对应字段,后写者赢。 这里的"整体替换"是关键:config 不是深合并,而是整个换掉。仓库自己的注释(packages/bundle/base/cordis.patch.yml:8-12)解释了为什么这么设计:值因模式而异的行不该放进 base,而应该让各模式 bundle 在自己的层里给出完整配置,保证任意一行至多由一个 bundle 层加一个用户层拥有。这让"谁配置了这行"永远有明确答案,排查配置问题时不必在多层深合并的结果里考古。name 字段是校验位:补丁声明的名字与目标行不一致会被拒绝并警告,防止你改错了行还以为改对了。
规则二:insert 把新行追加到目标 group 的 config(没有 id 则追加到顶层),并立即把新行建入 id 索引。 注释(vendor/include/src/index.ts:93-98)写明动机:补丁列表是一层一层叠加的,如果 insert 的行不进索引,靠后的层就永远无法配置或禁用靠前的层插入的行——insert 即时入索引,是为了"层能配置更早层插入的行"。索引递归包含 group 的子行,所以补丁可以嵌套地指向树深处。id 没有命中的补丁只 warn 并跳过,不致命——宽容的默认值,配合 --dump-config 就能看到哪条没生效。
规则一配一个实例。想改 Web 端口?packages/bundle/web-app/cordis.patch.yml:178-186 的 webserver 行写着 port: !!js ctx.webStartup.port ?? 3080;在你的 profile cordis.patch.yml 里写一条 - id: webserver, config: {port: 4000}(语义示意,实际还需带上该行其余必需字段),它会在第②层整体换掉 bundle 层的 config。这种 !!js 标量是有表达式的节点:行 config 在该行 inject 的服务就绪后、于行自己的上下文里求值;disabled 则在每次挂载决策时于 loader 上下文里求值。base 层的 hmr 行就是后者的例子——disabled: !!js "!ctx.get('profileContext')" 形态的表达式让同一行在"有 profile 上下文"时才挂载(packages/bundle/base/cordis.patch.yml:26-31)。
别忘了 PatchOptions 不止 id 与 config:inject、intercept、isolate、disabled 同样可被补丁整体替换。也就是说,上层不只能改一个插件的参数,还能改它声明的服务依赖、给子树合并拦截配置、调整作用域隔离——组装语言能表达的东西,远超"改配置项"。这也是为什么补丁的粒度必须停在条目级:再细,就说不清"谁拥有这行"了。
这个机制容易让人联想到 Git 的层叠与 CSS 的层叠:都是"后来者覆盖先来者"。但有一个本质差别值得点破:Git 叠的是文本行,CSS 叠的是单个属性,而这里被替换的是整棵服务树的一个条目——它的 config、它的 disabled 条件、它挂载的插件实现。一条补丁换掉 agent-loop 那一行,换的就是驱动 Agent 的那个插件。粒度是"条目",不是"值"。
规则一与规则二的配合,有一个现成的教科书例子。base 层 insert 了 hmr 行,而 headless 组合包想关掉它——packages/bundle/headless/cordis.patch.yml:33 只有两行:- id: hmr 加 disabled: true。这条补丁在 bundle 层序(base 之后、用户层之前)按 id 命中 base insert 的行(规则二的索引让它可被命中),整体替换其 disabled 字段(规则一),于是无界面场景下默认不带配置重载;你的 profile 若想要,再在第②层把 disabled: false 写回去即可——后写者赢。三层对话,每一层的意图都一目了然。sdk 与 acp 组合包用的是同样的两行(sdk-app 的 :24,acp-app 的 :23)。
dump-config:每一行配置都能溯源
叠加算法的直接产物是一个诊断命令。apps/cli/src/dump-config.ts:32 的 runDumpConfig 调用 renderConfigDump(packages/boot/app-boot/src/index.ts:431),它不做启动、不求值 !!js 表达式,而是把与 boot() 完全相同的 applyEntryPatches 跑一遍,对"前 k 层"逐层快照 diff,输出一份带注释的分段 YAML,每段标注 # == <来源>, patched by <层>。
dsh --profile web --dump-config顺带一提实现上的对称性:重载走的 readProfilePatches 不依赖启动时装载的结果,而是逐层现场重读——bundle 层以 userLayer: false 重新装载,profile 与 home 的补丁文件经 loadOptionalPatches 现读,overlay 与遥测开关照旧追加(profile-context.ts:63-77)。dump、重载、启动跑的是同一份叠加代码,不存在"dump 看见的"与"boot 用的"两套算法,这是 --dump-config 的结论值得信赖的根本原因。
这份输出的形态值得看一眼再上手:它不是一份"最终生效配置",而是一份按层分段的 YAML 剧本——每一段以 # == <来源> 开头、标注被哪一层改过,bundle 层、profile 层、home 层、overlay 各成一节。因为 diff 是逐层快照比对出来的,你能直接看到某一行从哪个 bundle 来、被哪条补丁换成了什么。输出契约是:它打印出的任何条目,都可以由你自己的 patch 替换。典型用法有两个。排查"为什么我的配置没生效":先 dump,找到那行,看它最后被哪一层写过——八成是更高层的某条补丁整体覆盖了你的 config,而整体替换意味着你的部分字段不是被合并而是被丢掉了。恢复诊断:--dump-default-config 跳过用户层与 overlay,只输出默认配方,用于用户层写坏、进程起不来的场景,对照它手工修复自己的 cordis.patch.yml 即可。配置里那些 !!js 表达式节点在 dump 中保持原样,因为 dump 不求值;它们怎么求值、Loader 怎么并发挂载各条目,属于 Cordis 内核与启动链路,分别在第 4 篇与第 3 篇展开。
小结
我们看到了组装的完整闭环:319 个包按 54 个组组织,运行时只加载 profile 声明的那部分;profile 用 dsh.profile.bundles 列出组合包,bundle 用 dsh.bundle.patch 指向自己的补丁文件,双锚点解析加 BFS 出的运行时包表保证所有人共享一份 cordis;readProfilePatches 把 bundle 层、profile 层、home 层、--patch overlay、遥测开关五层拍平,applyEntryPatches 以"按 id 整体替换、insert 即时建索引"两条规则合成最终条目列表;--dump-config 让合成结果每一行可溯源。机制本身已经讲完,但它发生在进程内:补丁数组如何变成挂载中的插件树,argv 如何一路传进树里——下一篇启动链路见。