跳到主要内容

数据截至 (上游 commit 99f6f02fecdb)

第 1 章 · 底座:Cordis 插件树与「配置即产品」的分层组合

30 秒导读: DeepSeek Harness(下称 dsh)里没有一个「主程序」把各部件 new 出来。它的产品形态——是网页版还是一次性命令行、用哪个模型适配器、会话存哪儿——完全由一张插件行表决定,而这张表是几个 YAML 补丁文件层叠算出来的。本章讲清楚支撑这件事的框架:Cordis(vendor 里 pin 死的插件框架)和 dsh 在它之上的组装层。读懂本章,后面 02–06 章的「某某也是一个插件」才不会显得像魔法。

本章只讲底座。agent 主循环、会话日志、工具执行分别留给 第 3 章第 2 章第 4 章


1. 先看结果:一个产品是怎么被「配」出来的

1.1 一句话直觉

把 dsh 想成一台接线盘

  • 每个能力(模型调用、会话存储、bash 工具、网页 UI……)是一块独立的插件板。
  • 一份 YAML 行表说明「这次插哪些板、每块板拨到什么档位」。
  • 换产品不是改代码,是换那份行表。

1.2 真实的一行长什么样

这是 base bundle 里几行原样的配置(packages/bundle/base/cordis.patch.yml:24-37):

- id: llm
name: '@deepseek-ai/dsh-llm'

- id: session
name: '@deepseek-ai/dsh-session'

- id: typert
name: '@deepseek-ai/dsh-typert-registry'

- id: typert-loader
name: '@deepseek-ai/dsh-typert-loader'

- id: typert-gateway
name: '@deepseek-ai/dsh-api-gateway'

一行三个字段就够了:id(这一行的稳定名字,后面所有补丁靠它定位)、name(要 import 的包)、可选的 config(传给这个插件的配置)。行的顺序不代表加载顺序——文件开头的注释明说了这点(packages/bundle/base/cordis.patch.yml:12-13),真正的启动次序由「谁的依赖先齐」决定,这是本章 §3 的内容。

1.3 「换一行就换实现」的三个真实例子

想换掉什么改哪一行效果
模型后端llm-deepseek 这一行的 namebase/cordis.patch.yml:450llm 这个服务定义不动,换掉的是它的适配器实现
会话落盘方式session-persistence-jsonl 行(base/cordis.patch.yml:98-101换成别的 provider 包,会话日志改存别处
系统提示词人格上层 bundle 用 - id: system-prompt 覆盖 config(headless/cordis.patch.yml:7-10web-app/cordis.patch.yml:16-19同一个插件,两个产品两套人格

再看一个更彻底的:整块能力按平台开关。

- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'

- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'

packages/bundle/base/cordis.patch.yml:178-186!!js 是一个自定义 YAML 标量标签,值是一段延迟求值的 JS 表达式,§5.4 讲它什么时候被求值。)

1.4 记住这条主链

dsh --profile web "帮我改代码"

├─(1) 解析启动参数 apps/cli/src/args.ts
│ 只认自己的 --profile / --patch,其余原样交给被启动的 app

├─(2) 叠补丁 apps/cli/src/profile-boot.ts
│ bundle 层 → profile 层 → home 层 → --patch 覆盖层

├─(3) 起根 Context packages/boot/app-boot/src/index.ts boot()
│ new Context() → 装 Loader → 挂 cordis:include

└─(4) 逐行 import + 装载 vendor/loader、vendor/cordis
每一行 = 一次 ctx.plugin(插件, 行的 config) = 一个 Fiber

下面先把 (4) 里的 Cordis 讲透(§2–§4),再回头讲 (1)(2)(3) 的组装层(§5)。


2. Cordis 的三个名词:Context / Service / Fiber

Cordis 是 dsh **源码内嵌(vendored)**的插件框架,源码就在 vendor/cordis/src/ 下,锁死在一个上游提交上。它的整个心智模型只有三个词。

2.1 三个词的分工

名词白话你在代码里怎么见到它源码
Context一个「能拿到别人、也能被别人拿到」的作用域对象插件函数的第一个参数 ctxvendor/cordis/src/context.ts:42
Service挂在 ctx 上的一个具名能力(ctx.llmctx.agentsclass X extends Servicevendor/cordis/src/service.ts:11
Fiber「这个插件这一次被装载」的运行时实例ctx.fiberctx.plugin() 的返回值vendor/cordis/src/fiber.ts:184

一句话串起来:一次 ctx.plugin(P, config) 产生一个 Fiber,Fiber 给插件一个子 Context,插件在这个 Context 上注册若干 Service 和监听器。

2.2 一个插件长什么样

// 示意,非源码
export const name = 'my-plugin'
export const inject = ['llm'] // 声明:没有 llm 服务我就不启动

export function apply(ctx, config) {
ctx.llm.registerAdapter(['my-provider'], myAdapter) // 用别人的服务
ctx.on('some/event', handler) // 挂监听器
}

重点看两处:inject声明而不是查找——框架保证 apply 跑起来时 ctx.llm 一定在;ctx.on 注册的东西不用你手动清理(§4)。


3. Context:一个会自己解析依赖的代理对象

3.1 它解决的小问题

依赖注入通常要么用容器(container.resolve('llm'),字符串满天飞),要么用构造函数参数(层层透传)。Cordis 选了第三条:ctx.llm 这个普通属性读取本身就是一次依赖解析

3.2 实现:构造函数返回的是 Proxy

Context 的构造函数里有一行反直觉的代码——它 return 了一个代理,而不是 this

const self = new Proxy<this>(this, ReflectService.handler)

vendor/cordis/src/context.ts:74,末尾 return self。)所有属性读写都会落到 ReflectService.handlervendor/cordis/src/reflect.ts:135)。

get 陷阱的逻辑,按顺序是:

  1. 是自有属性 → 直接返回;
  2. 是 accessor(ctx.accessor() 声明的计算属性)→ 走它的 get
  3. 否则派发 internal/get waterfall,默认行为是沿 Fiber 链向上找fiber.store?.[prop] 命中就返回,否则看父 Fiber,直到跨出隔离域或走到根就抛错(vendor/cordis/src/reflect.ts:152-167)。

关键在第 3 步找的是 fiber.store ——只有本 Fiber 声明过 inject 的服务才在这个 store 里(store 由 Fiber._checkImpl 填,vendor/cordis/src/fiber.ts:597-609)。所以没声明就读,报的错是 cannot get property "x" without inject。这不是防呆,是整套依赖模型的基石:谁用谁声明,框架才知道该在什么时候把谁重启。

3.3 三种造子 Context 的方式

三个方法都不改父 Context,都返回一个新的子对象。

方法干什么典型用途源码
extend(meta)原型继承出一个子 ctx,用 meta 的自有属性遮蔽继承来的框架内部给每个 Fiber 造它的 ctxfiber.ts:236vendor/cordis/src/context.ts:99-107
isolate(name, label?)name 这个服务在子树里解析到另一个隔离域一个 preset 想独占一份 planModecontext.ts:121-125
intercept(name, config)给子树里的插件注入「读某服务时额外的配置」按调用方定制服务行为context.ts:158-166

isolate 的实现只有三行,本质是在 symbols.isolate 这张「服务名 → 域标签」的映射上再叠一层原型:

const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })

服务注册和查找都用这个标签做 key(reflect.ts:286-294providereflect.ts:238_getImpl),所以「换一个标签」就等于「换一个平行宇宙里的同名服务」。

配置文件里也能直接用它。真实的 agent preset 就是这么给「计划模式」开独立域的(apps/cli/config/agent-presets/standard/agent.cordis.yml:104-108):

- id: planning
name: cordis:group
group: true
isolate:
planMode: true

intercept 的配置怎么合并,写在 Service[symbols.resolveConfig] 里:沿 symbols.intercept 的原型链往上走,越靠近根的越先应用vendor/cordis/src/service.ts:86-102)。

3.4 ctx.<name>ctx.get(name) 的分工(全库约定)

这是贯穿整个 dsh 代码库的一条硬规矩:

  • ctx.<name>:只用于自己声明过 inject 的服务。 它走上面那条 Fiber 链查找,是拓扑敏感的——同名服务在不同隔离域里可能是不同实例。
  • ctx.get(name):用于可选服务。 它绕过 inject 要求,直接按隔离标签读全局服务表,拿不到就返回 undefinedvendor/cordis/src/reflect.ts:233-242)。

真实用法:网关要枚举「当前所有活着的服务」,逐个 this.ctx.get(serviceKey),拿不到就跳过(packages/api/gateway/src/index.ts:126);启动器要判断「这套组合有没有挂 HMR」,写 ctx.get('hmr') === undefinedapps/cli/src/profile-boot.ts:279-283)。两处都是「有就用,没有就走别的路」,正是 ctx.get 的语义。


4. Service 与 Fiber:声明依赖,然后被自动上下线

4.1 Service:构造即注册

Service 基类的构造函数只做一件事——把自己登记到当前 Context:

self.ctx.reflect.provide(name, self, this[symbols.check])

vendor/cordis/src/service.ts:57。)所以服务类的写法就是「super(ctx, '名字') 一行完事」:

export class LlmRuntime extends Service {
constructor(ctx: Context) {
super(ctx, 'llm')
}

packages/llm/llm/src/index.ts:284,292-294。)

依赖用静态字段声明。网关声明它需要 typert 注册表:

export class TypertGatewayService extends Service implements TypertGateway {
static inject = ['typert']

packages/api/gateway/src/index.ts:90-91。同样的写法散布全库,例如 packages/session/session-persistence-jsonl/src/index.ts:124static inject = ['sessions']。)

inject 支持数组和对象两种形态,对象形态的值就是给那个服务的 intercept 配置;Inject.resolve 把两种形态和类继承来的声明统一成一张平表(vendor/cordis/src/registry.ts:71-88)。

4.2 Fiber:一次装载的运行时实例

ctx.plugin(P, config) 干的事很短:解析出插件的可执行体、复用或新建 Plugin.Runtime 记录、然后 new Fiber(...)vendor/cordis/src/registry.ts:316-336)。同一个插件被装载 N 次就有 N 个 Fiber,共享一个 Runtime。

Fiber 有六种状态(vendor/cordis/src/fiber.ts:147-154):

状态含义
PENDING依赖没齐,等着
LOADING正在跑插件主体
ACTIVE已加载,服务对外可见
FAILED插件主体或配置校验抛了错
UNLOADING正在跑 disposer
DISPOSED已移除,不会再起来

4.3 「依赖齐了没」是怎么算出来的:epoch 字符串

这是 Cordis 最巧的一处。Fiber 不用布尔值记「依赖是否满足」,而是把依赖状态编码成一个字符串vendor/cordis/src/fiber.ts:611-623_refresh):

for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break }
epoch += ':' + impl.fiber.uid
}
this._setEpoch(epoch)

于是:

  • 少任何一个依赖 → epoch 是 INACTIVE
  • 依赖都在 → epoch 是各提供者 Fiber uid 拼出来的指纹,比如 :3:7

_setEpochfiber.ts:625-639)只比较新旧字符串:

  • INACTIVE 变成别的 → _reload(),起来;
  • 其他任何变化(包括提供者换了个实例导致指纹从 :3:7 变成 :9:7)→ _unload(),卸掉;卸完发现新 epoch 又有效,_unload 结尾会自己再调 _reloadfiber.ts:688-695)。

这就是热插拔的全部机制。用 ASCII 看这个环:

依赖变化(reflect.notify)


_checkImpl → _refresh → 算出新 epoch

├─ 旧 INACTIVE,新有效 ──▶ LOADING ──跑 apply()──▶ ACTIVE
│ └──抛错──▶ FAILED

└─ 其他变化 ──▶ UNLOADING ──跑完所有 disposer──▶

新 epoch 还有效? ──是──▶ 回到 LOADING
└──否──▶ PENDING

「谁触发依赖变化」也很明确:任何服务的注册/注销都会调 ReflectService.notify,它遍历所有 Fiber,凡是 inject 里有这个名字的就重算一遍(vendor/cordis/src/reflect.ts:314-330)。

4.4 ctx.effect():注册即可逆副作用

问题: 插件运行时会往外面挂东西——监听器、注册表条目、定时器、打开的文件。插件被卸载时,这些必须一件不落地撤掉,否则热重载一次就泄漏一次。

Cordis 的答案: 不给你「注册」和「注销」两个 API,只给你一个 ctx.effect(执行体),执行体返回它自己的清理函数

// 示意,非源码
ctx.effect(() => {
const handle = registry.add(entry) // 副作用
return () => registry.remove(handle) // 它的逆操作,就地写清楚
}, 'my.register()')

框架收集这些清理函数,在 Fiber 卸载时逆序执行(vendor/cordis/src/fiber.ts:418-561effect_unloadfiber.ts:675-696 清空 _disposables)。执行体也可以是生成器或 async 生成器,yield 出的每个清理函数都会被逐个收集(fiber.ts:375-395)。

dsh 里的真实用法——LLM 服务注册适配器:

const dispose = this.ctx.effect(function* (this: LlmRuntime) {
if (providers.length === 0) throw new LlmError(...)
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => {
released = true
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
this.emitAdaptersUpdated()
}
}.bind(this), 'llm.registerAdapter()')

packages/llm/llm/src/index.ts:365-383registerAdapter。)注意最后那个字符串 'llm.registerAdapter()'effect 标签——fiber.getEffects() 会把它们组织成一棵树用于诊断(fiber.ts:568-572)。

框架自己也全部走这条路:

  • ctx.on(...) 内部是 EventsService.registerthis.ctx.fiber.effect(...),标签形如 ctx.on("llm/stream")vendor/cordis/src/events.ts:254-260);
  • ctx.provide(...) 也是一个 effect,标签 ctx.provide("llm")vendor/cordis/src/reflect.ts:277-304);
  • 连「子插件」本身都是父 Fiber 上的一个 effect,标签 'ctx.plugin()'vendor/cordis/src/fiber.ts:265-297)。

所以整棵插件树是一棵 effect 树:卸掉任何一层,它下面的所有注册(包括子插件)都会按逆序退干净。dsh 的仓库规约把这条写成硬性要求——每个注册表的 register() 都必须返回 disposer,并用「卸载 Fiber 后观察条目消失」的测试来证明。


5. 事件:五种派发模式,和 waterfall 的 next() 陷阱

5.1 五种模式一览

ctx 上直接混入了五个派发方法(混入发生在 vendor/cordis/src/reflect.ts:222)。语义定义见 vendor/cordis/src/events.ts:32DispatchMode)与各方法实现:

方法同步/异步停不停返回什么实现
emit同步,不等 Promise全跑events.ts:194-196
parallel并发,allSettled 后聚合错误全跑Promise<void>events.ts:183-187
serial依次 await遇到 bail 值就停第一个 bail 值events.ts:204-209
bail同步依次调遇到 bail 值就停第一个 bail 值events.ts:217-222
waterfall由监听器自己决定谁不调 next() 谁就终止最外层监听器的返回值events.ts:234-243

「bail 值」的判定很宽松:只要不是 null/false/undefined 就算数(events.ts:13-15isBailed)。

5.2 派发前的两次筛选

EventsService.dispatchevents.ts:165-175)在真正调监听器之前做两件事:

  1. 如果第一个参数是对象/函数,就把它当作 thisArg 取走——这既是监听器的 this,也是过滤器的来源
  2. hook.global || !filter || filter.call(thisArg, hook.ctx) 过滤监听器。

Service 提供的默认过滤器是「隔离域必须一致」(vendor/cordis/src/service.ts:61-63)。所以像 this.ctx.waterfall(this, 'llm/stream', ...) 这样把服务实例当 thisArg 传进去(packages/llm/llm/src/index.ts:989-994),效果是:只有和这个 llm 实例处在同一隔离域的监听器会收到事件。多 agent 各自隔离时,这一条决定了事件不会串台。

5.3 waterfall:不调 next() 就等于否决

waterfall 的实现只有 9 行,值得逐行看(vendor/cordis/src/events.ts:234-243):

waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop() // 最后一个参数是「内置行为」
const next = () => {
const cb = cbs.shift() ?? inner // 取下一个监听器,取完了就是内置行为
return cb(...args)
}
args.push(next) // 每个监听器拿到的最后一个参数就是 next
return next()
}

读法:这是一条洋葱链。每个监听器包在下一层外面,它拿到的最后一个参数是 next

ctx.waterfall(this, 'llm/stream', options, () => 真正去调模型)

[校验监听器] ──next()──▶ [路由监听器] ──next()──▶ [内置行为:调 adapter]
│ │
└── 不调 next() ─────────┴──▶ 链条到此为止,内置行为根本不会执行

这就是全库反复强调的那条规矩:waterfall 监听器必须调用 next() 才能向下委托;直接 return 会短路整条链,包括框架的内置行为。 这不是风格建议——从上面的实现能直接看出,cbs.shift() 只在 next() 里发生,不调用就没有下一棒。

真实的「包一层」写法,LLM 的运行时不变量校验器:

ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })

packages/llm/llm/src/invariant.ts:88prepend: true 让它排在最外层,global: true 让它跳过隔离域过滤。)它调了 next() 拿到下游的 chunk 流,再包一层校验后返回——典型的中间件。

框架自己也靠 waterfall 开放了几个关键接缝(vendor/cordis/src/events.ts:329-352Events 接口):internal/config(解析插件配置)、internal/update(配置热更新,不调 next() 就是否决重启)、internal/get / internal/set(服务读写)。

5.4 顺带解释:!!js 表达式什么时候求值

internal/config 是个 waterfall,在 Fiber 依赖已经就绪、即将执行插件主体时派发(vendor/cordis/src/fiber.ts:641-644_resolveConfig)。Loader 在这个 waterfall 上挂了一个全局监听器,对条目配置做插值(vendor/loader/src/index.ts:92-101),插值函数用 with (ctx) { eval(expr) } 把表达式跑在该条目自己的 Context 上(vendor/loader/src/config/utils.ts:5-9)。

于是回头看 §1.3 的配置就通了:

  • root: !!js dshHomePath('sessions') 能用,是因为 boot 在根 Context 上 ctx.provide('dshHomePath', dshHomePath)packages/boot/app-boot/src/index.ts:770);
  • task: !!js ctx.headlessStartup.task 能用,是因为那一行同时写了 inject: [headlessStartup]packages/bundle/headless/cordis.patch.yml:31-35)——依赖没齐时这一行根本不会被求值。

5.5 类型化事件:声明合并

事件名是字符串,但类型是静态的。做法是每个包用 declare module 往框架的 Context / Events 接口上合并自己的成员:

declare module '@deepseek-ai/cordis' {
interface Context {
llm: LlmRuntime
}

interface Events {
/**
* ...
* @mode waterfall
*/
'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
}
}

packages/llm/llm/src/index.ts:47-68。)三个收益:

  1. ctx.llm 有类型,不用断言;
  2. ctx.on('llm/stream', ...) 的参数和返回值被检查,next 的签名也在其中;
  3. JSDoc 里的 @mode waterfall 是给人和工具读的派发模式标注——库内规约要求每个事件都标。

Loader 层同样这么扩展框架类型,比如给 Fiber 加上 entry?: Entryvendor/loader/src/index.ts:23-43)。


6. dsh 怎么用 Cordis 组装出产品

到这里,Cordis 讲完了。剩下的问题是:那张「插件行表」从哪来、怎么变成 ctx.plugin() 调用。

6.1 Loader:把一行配置变成一个插件

Loader 本身也是个服务(vendor/loader/src/index.ts:65class Loader extends EntryTree)。行表里的每一行是一个 Entryvendor/loader/src/config/entry.ts:52),字段就是 §1.2 见过的那几个(EntryOptionsentry.ts:9-22)。

一个 Entry 启动时干两件事(entry.ts:277-301):

plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack))
// ...
fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)

先 import 包,再 ctx.plugin(它, 这一行的 config)一行配置 = 一次 ctx.plugin = 一个 Fiber,闭环了。

unwrapExportsvendor/loader/src/index.ts:191-199)负责抹平 ESM/CJS 导出差异,逻辑是 exports.default ?? exports这行代码解释了全库的第二条约定(见 §7)。

6.2 Include:把 YAML 文件挂成一棵子树,并在挂之前打补丁

Include 是一个「配置文件形态的 EntryTree」(vendor/include/src/index.ts:174)。它的 [Service.init] 读文件、解析、然后 apply(include/src/index.ts:273-289);apply 之前先过一道补丁(include/src/index.ts:315-321_apply)。

补丁算法就是 applyEntryPatchesvendor/include/src/index.ts:58-128),只有三种操作:

补丁形态效果
{ insert: [...行] }往行表尾部追加若干行;带 id 时追加进那个 group 内部
{ id: 'x', config: {...} }用新值整体替换目标行的 config(不是深合并)
{ id: 'x', disabled: true }覆盖目标行的任意字段,disabled 只是其中之一

三条要点,都直接写在这个函数里:

  1. 不改原数据。 开头 data = structuredClone(data),返回值永远与输入脱钩——否则配置热重载时「撤掉一个补丁」永远撤不干净(include/src/index.ts:59,及其上方注释)。
  2. 后插入的行也能被后续补丁定位。 插入后立刻 buildMap(insert) 把新行加进 id 索引(include/src/index.ts:101),所以「bundle 插了一行,用户层再改这一行的 config」是成立的。
  3. 打空了会警告,不会静默。 匹配不到 id 就 warn(...) 并跳过(include/src/index.ts:112-114)。

因为「替换而非合并」这条,base bundle 的注释专门说明了一条设计纪律:按模式而异的配置值不放在 base 层,每个模式 bundle 自己完整重述(packages/bundle/base/cordis.patch.yml:6-10)。所以你会看到 web-appheadless 都把 session-query-sqlite / system-prompt 的整块 config 重写一遍。

6.3 profile 与 bundle:两个名词一次说清

概念是什么落在磁盘哪里
bundle一个 npm 包,它的 package.json 里有 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },指向自己导出的补丁层比如 packages/bundle/base/package.json
profile一个目录,package.json 里的 dsh.profile.bundles有序的 bundle 列表,同目录下的 cordis.patch.yml 是用户自己的补丁层$DSH_HOME/profiles/<name>/

类型定义在 packages/boot/app-boot/src/profile.ts:42-96DshBundleManifest / DshProfileManifest / ProfileLayer / Profile)。

resolveProfileDirprofile.ts:104-111)把名字拼成目录,并拒绝 ''...、含分隔符、以及 node_modules 这几种名字(最后一个是因为启动器在同级维护着一个扁平模块回退目录)。

loadProfileprofile.ts:371-403)做四件事:目录不存在就按内置模板初始化 → 读 profile 清单 → 逐个把 bundle 名解析成目录并读它的补丁文件 → 读用户自己的 cordis.patch.yml。两个细节值得记:

  • bundle 解析是「安装目录优先,profile 目录其次」profile.ts:344-355resolveBundleDir)。这是一条明确契约:内置 bundle 永远来自跑着的这套 dsh 安装,不会被 profile 本地副本顶掉。
  • 列了一个没有 dsh.bundle 的包 = 报错,不是「当作没有补丁」(profile.ts:391-394)。配置错了要响。

内置模板只有两个(profile.ts:114-117):

export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}

「web 版」和「一次性命令行版」的全部差别,就是这个数组的第二项。

6.4 真实的层叠顺序

根配置是一个空数组——profile-boot.ts:60-64PROFILE_ROOT_CONFIG 就是三行注释加一个 [],而且每次启动都重写一遍(profile-boot.ts:98-103prepareProfile,注释解释了原因:Loader 有把当前树写回文件的行为,不重置会把组合出来的行「烤」进根文件,下次启动就重复插入)。

所以整棵树 100% 由补丁叠出来,顺序是(apps/cli/src/profile-boot.ts:122-129allPatches):

空行表 []

├─① bundle 层 按 dsh.profile.bundles 顺序,多个 bundle 的补丁首尾相接

├─② profile 用户层 $DSH_HOME/profiles/<name>/cordis.patch.yml

├─③ home 用户层 $DSH_HOME/cordis.patch.yml ← 机器级偏好,压过②

└─④ 覆盖层 --patch 文件 + 开关派生补丁(如 DSH_TELEMETRY_DISABLED)


最终行表 → Include 挂载 → 每行一个 Fiber

composeEntriespackages/boot/app-boot/src/profile.ts:413-420)把所有层拍平成一次 applyEntryPatches 调用——注意是一次,不是逐层调用。这一点很重要:只有单次调用,--dump-config 打印的结果才和真正挂载的结果逐字节一致(packages/boot/app-boot/src/index.ts:348-356 的注释明确了这个约束)。

开关派生补丁的例子(profile-boot.ts:80-83):DSH_TELEMETRY_DISABLED 只要非空(哪怕是 '0'),就生成一条 { id: 'session-telemetry-otel', disabled: true } 追加到最后。隐私开关宁可误关不可误开。

用户层还是热的watchUserPatchespackages/boot/app-boot/src/index.ts:232)监听两个用户补丁文件,改动后用 composeLive() 重新拼一遍整叠补丁再刷新(profile-boot.ts:240-245)。重拼时每次都 structuredClone——因为 insert 的行是按引用推进树里的,复用同一批解析结果会把用户的临时覆盖永久烤进 bundle 的那行(该注释就在 profile-boot.ts:235-239)。

6.5 看一眼真相:dsh --profile web --dump-config

这条命令不启动任何插件,只把上面的层叠算一遍打出来(apps/cli/src/dump-config.ts:30-52runDumpConfig)。它喂给渲染器的层顺序和启动时完全一致:每个 bundle 一层、profile 的 cordis.patch.yml、home 层、每个 --patch 一层。

渲染器 renderConfigDumppackages/boot/app-boot/src/index.ts:379)额外做了一件贴心事:它对「前 k 层」各算一次快照,逐位置比对,从而知道每一行是哪个文件贡献的、又被哪些层改过,输出成注释:

# == @deepseek-ai/dsh-base
- id: llm
name: '@deepseek-ai/dsh-llm'
# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app
- id: system-prompt
...

(注释格式见 packages/boot/app-boot/tests/config-dump.spec.ts:83-85 的断言。)!!js 表达式原样打印、不求值——dump 是离线的,没有 Context 可以求值。

相关的两个变体:--dump-default-config 跳过用户层(用于「用户的 cordis.patch.yml 写坏了」时的救援诊断,args.ts:134profile.ts:366-369);启动器只解析自己的旗标,第一个它不认识的 token 之后全部原样交给被启动的 appapps/cli/src/args.ts:8-13,126-129),所以 dsh --profile web -h 打印的是 web app 的帮助,不是启动器的。

6.6 启动那一刻发生了什么

boot() 很短,值得整体理解(packages/boot/app-boot/src/index.ts:757-802):

new Context()
→ ctx.provide('dshHomePath', dshHomePath) // 给 !!js 表达式用的工具
→ await ctx.plugin(Loader) // 装 Loader 服务
→ prepare?.(ctx) // 宿主注入:命令行快照、环境快照
→ mountRootInclude(ctx, 根配置, 全部补丁) // 挂 cordis:include,补丁在这里进去
→ await ctx.get('loader')?.await() // 等整棵树稳定
→ assertEntriesActivated(ctx, binName) // 审计:有没有行没起来

两处设计值得记:

  • 失败分两段贴标签。 prepare 抛错叫 host preparation failed,之后抛错叫 plugin tree failed to load;catch 里还会一路挖 error.cause 找到最深的原始错误,把它的 stack 附在诊断后面(index.ts:786-801)。
  • 根 include 的 id 是钉死的 'include'index.ts:511-522),因为它会出现在失败链里,随机 id 会让启动诊断和快照测试不稳定。

7. 贯穿全库的两条导出约定

这两条是读后面任何一个包之前都要先知道的。

7.1 服务包默认导出 Service 类,函数插件只用具名导出

插件形态怎么导出真实例子
服务包export default XxxService(同时通常也具名导出,供类型使用)packages/llm/llm/src/index.ts:1026export default LlmRuntimepackages/core/agent/src/index.ts:706export default AgentRegistry
函数插件具名导出 name / inject / Config / apply不要 defaultpackages/llm/llm-retry/src/index.ts:20-21,99export const name = 'llm-retry'export const inject = ['agents']export function apply(ctx, config, internals)

为什么不能混: Loader 的 unwrapExports 取的是 exports.default ?? exportsvendor/loader/src/index.ts:191-199)。一个模块要是同时有 default 和具名的 inject,Loader 会拿走 default,整个模块命名空间——连同 inject 声明——被丢掉。结果是插件在依赖还没就绪时就被启动,报的错和真实原因八竿子打不着。库里为此留了专门的事故复盘文档。

7.2 可选服务一律 ctx.get(name)

已在 §3.4 讲过,这里只重复结论:ctx.<name> 留给声明过 inject 的必需依赖,ctx.get(name) 用于「有就用、没有就退化」的可选依赖。 前者拓扑敏感(走 Fiber 链),后者读全局服务表(vendor/cordis/src/reflect.ts:233-242)。


8. 边界与坑

  • waterfall 忘了调 next() 是最容易犯、最难查的错。 表现是「某个功能整个不生效」,因为你连内置行为一起否决了。写 waterfall 监听器时,先把 next() 写进去再想别的。
  • patch 替换 config 是整体替换,不是深合并。 想改一个键就得把整块重述一遍。这是 applyEntryPatchestarget[key] = valuevendor/include/src/index.ts:121-124)决定的。
  • 行的顺序不是加载顺序。 加载次序由服务可用性驱动。想控制「谁在谁之前」,正确做法是声明 inject,不是调行序。
  • !!js 表达式跑在 with (ctx) { eval(...) }vendor/loader/src/config/utils.ts:5-9)。它能访问什么,取决于该条目 Context 上有什么——也就是取决于它的 inject
  • vendor/ 是 pin 死的源码副本,不是 npm 依赖。上游同步有专门流程,本章引用的所有 Cordis 行号 as-of sourceCommit
  • 单次 applyEntryPatches 有它的角落行为。 「后一层去 patch 一个由普通配置替换引入的 group 子行」这种情况,单遍 id 索引看不见——renderConfigDump 的注释把这点当作「dump 必须和 boot 用同一次调用」的理由明写了出来(packages/boot/app-boot/src/index.ts:350-355)。

9. 接下来读什么

  • 会话事件日志怎么成为唯一事实源 → 第 2 章
  • 主循环的 turn / step 状态机 → 第 3 章
  • 工具注册表与三段式执行 → 第 4 章
  • 「换一个 Provider 就换一个执行世界」这条接缝的完整形态 → 第 5 章
  • 每会话独立组装(preset)与多前端外壳 → 第 6 章

10. 代码地图

主题文件路径符号名
Context 代理与作用域派生vendor/cordis/src/context.tsContextextendisolateintercept
属性读写陷阱 / 服务表vendor/cordis/src/reflect.tsReflectService.handlerget_getImplprovidenotify
服务基类与 intercept 合并vendor/cordis/src/service.tsServiceService[symbols.resolveConfig]
依赖声明与插件注册vendor/cordis/src/registry.tsInject.resolvePlugin.BaseRegistryService.plugin
Fiber 生命周期与 effectvendor/cordis/src/fiber.tsFiberFiberStateeffect_refresh_setEpoch_reload_unloadupdate
事件派发五模式vendor/cordis/src/events.tsEventsService.dispatchemitparallelserialbailwaterfallregisterEvents
条目树与配置解释vendor/loader/src/index.tsLoaderunwrapExportslocate
一行配置 → 一个 Fibervendor/loader/src/config/entry.tsEntryEntryOptions_init_start
!!js 求值vendor/loader/src/config/utils.tsevaluateinterpolateisJsExpr
补丁算法vendor/include/src/index.tsapplyEntryPatchesIncludeentryListSchema
profile / bundle 解析packages/boot/app-boot/src/profile.tsProfileProfileLayerresolveProfileDirloadProfileresolveBundleDircomposeEntriesPROFILE_TEMPLATES
启动与根 includepackages/boot/app-boot/src/index.tsbootmountRootIncludewatchUserPatchesrenderConfigDumploadOverlayPatches
层叠顺序与热更新apps/cli/src/profile-boot.tscomposeProfileallPatchesrunProfileprepareProfileresolveTelemetryPatch
命令行边界apps/cli/src/args.tsparseDshArgsresolveBoot
配置转储apps/cli/src/dump-config.tsrunDumpConfig
基础组合的真实行表packages/bundle/base/cordis.patch.ymlllmsessiontypert-gatewayagentsettingscredentialsllm-deepseek 各行
服务类导出范例packages/llm/llm/src/index.tsLlmRuntimeregisterAdapter'llm/stream'
函数插件导出范例packages/llm/llm-retry/src/index.tsnameinjectapply
static inject 范例packages/api/gateway/src/index.tsTypertGatewayServicestatic inject