上一篇跟着一次 turn 经过了十几种插件:模型适配器装请求,prompt 与 tools 在 step 前汇合,持久化订阅 SessionEvent,approval 和 sandbox 截住副作用,Web 再观察同一运行。问题是:这些模块凭什么可以替换,却不会在启动顺序、依赖或清理上彼此踩踏?
答案不是某个“插件管理器”对象,而是两层互补协议。启动前,applyEntryPatches() 把多个来源合成一棵确定配置树;启动后,Cordis 的 Context、Service 和 Fiber 决定能力在哪个作用域可见、何时激活、由谁释放。
阅读契约。读完以后,你应该能判断:一个 profile 覆盖为什么要重述完整 config;!!js 为什么不能在 config dump 阶段求值;extend() 与 isolate() 怎样产生不同的 service 解析;缺少 injection 时插件为什么停在 PENDING;以及配置重组与模块替换为什么必须进入同一 HMR 队列,却不能被视为同一种事务。
证据边界。本文固定在 ddefc45。DSH 把带补丁的 Cordis fork 放在 vendor/,因此 Context、Service 与 Fiber 的结论直接来自同一仓库快照;它证明的是组合和清理语义,不等于任意第三方插件都安全或兼容。
一、启动前先把多层配置压成一棵树
1.1 同一个算法同时服务 boot 和 dump
vendor/include 导出纯函数 applyEntryPatches(data, patches, warn)。composeEntries()、实际 boot include 与 dsh --dump-config 都调用它,而不是各自实现一套近似合并。于是诊断输出与真正挂载的树共享相同的 id 索引、插入顺序和警告规则。
这个约束很重要。若 dump 使用深合并、boot 使用整对象替换,一份看似正确的诊断配置仍可能启动成另一棵树。DSH 宁愿让错误尽早暴露,也不接受“显示值”和“运行值”分叉。
1.2 patch 是有顺序的操作,不是 YAML 深合并

PatchOptions 只有两类核心动作:按 id 找到条目并替换字段,或用 insert 添加条目。对 DSH 用户最容易踩的坑是:id-targeted patch 会替换整个 config,不会逐字段深合并。要保留的 bundle 字段必须重新写出。
- id: llm-deepseek
config:
model: deepseek-chat
apiKey: !!js process.env.DEEPSEEK_API_KEY
- insert:
- id: local-plugin
name: ./local-plugin.mjs
!!js 也不是 YAML 解析时立即执行的任意代码。Parser 把它保存成 expression node;Loader 等条目声明的 injections 可用以后,才在该条目自己的 context 中通过 internal/config 解析。--dump-config 则原样打印表达式,避免诊断命令因环境或副作用改变配置。
同一列表中,较早 insert 的条目会立刻进入 id 索引,后续 patch 可以继续配置或禁用它。未匹配的 id 不会静默吞掉,而是交给 warn();config dump 还会标出对应 layer。这让拼错插件 id 变成可见错误信号。
二、Context 决定服务在哪个局部世界可见
2.1 根 Context 先安装四个内建服务
Context 构造时创建根 Fiber,并安装 reflect、registry、events 与 logger。Context 本身是 Proxy:普通属性读取经过 service resolver,ctx.plugin()、ctx.inject()、ctx.on() 等方法则由内建服务混入。
所以插件拿到的 ctx 不是全局 service bag。它是一份带父链、isolation map、intercept map 与当前 Fiber 的作用域视图。两个插件即使都读取 ctx.llm,也可能因 scope label 不同而得到不同 provider。
2.2 extend 继承,isolate 改写某个 service 的解析域

extend(meta) 创建原型继承的 child context,父对象不被修改;没有 isolation 变化时,服务读取沿用同一 label。isolate(name, label) 则复制 isolation map,只为指定 service 写入新 label。下游对这个名字的 provide/get 都进入新的解析域,其他服务仍继承父 scope。
这正适合 Agent runtime:可以为一段 subtree 替换 LLM、session store 或 approval provider,而不必复制整个应用。相同 label 还可以让两条分支显式加入同一隔离域。intercept(name, config) 则不换实现,只把祖先到当前 scope 的 service config 按顺序合并。
| 操作 | 改变什么 | 不改变什么 |
|---|---|---|
extend() | 给 child context 增加 metadata。 | 不改 service label,不修改父 context。 |
isolate() | 为一个 service name 改写解析 label。 | 其他服务继续沿父 scope 解析。 |
intercept() | 给某个 service 的插件配置增加局部覆盖。 | 不替换 service provider。 |
provide() | 在当前 label 注册实现。 | 实现随 owning Fiber 卸载,不变成永久全局。 |
三、Service 把能力注册和所有权绑在一起
3.1 构造 Service 就登记 provider
Service 基类在构造函数里调用 ctx.reflect.provide(name, this, check)。这个 provide 不是裸写 Map:注册动作成为 owning Fiber 的 effect,Fiber 卸载时 provider 自动移除。
export class SessionStore extends Service {
constructor(ctx: Context) {
super(ctx, 'sessions')
}
}
await ctx.plugin(SessionStore)
const store = ctx.get('sessions')
这消除了“插件卸载了但 service 还指向旧实例”的悬挂状态。可调用 service、Config schema 与 intercept config 也继续绑定在同一对象契约上。消费者只声明注入名字,不直接 import 具体 provider。
3.2 injection 是激活条件,不是启动后的空值检查
Plugin runtime 声明需要的 service 后,Registry 创建 Fiber。依赖未满足时 Fiber 保持 PENDING;provider 出现后进入 LOADING,保存实现快照,再解析与校验配置;回调与 init hooks 完成才变成 ACTIVE。provider 被替换或消失时,旧 Fiber 会卸载或回到等待路径,而不是让业务代码在任意位置撞上 undefined。
这让“启动顺序”从手工排序变成依赖图。配置文件的行顺序仍会影响 tree 与 hook 顺序,但 capability 未就绪不会被伪装成已激活插件。
四、Fiber 让插件生命周期成为结构化所有权
4.1 每个 effect 都归当前 Fiber 负责
Fiber 跟踪 validated config、required service snapshot、effects、disposers、错误和状态迁移。插件通过 ctx.on()、ctx.provide() 或 ctx.effect() 建立的资源都会进入当前 Fiber;卸载时按逆注册顺序发起 disposer,并等待它们全部结算。
逆序保证后注册的同步撤销动作先开始,但 _unload() 使用 Promise.all 等待这一批清理,不能推导出异步 disposer 串行完成。若必须等事件流彻底停止后再终止 worker,应把两步放进同一个 async disposer 并显式 await;重复处置则会等待已经开始的清理,不再执行一遍。
4.2 HMR 先统一调度,再区分两种更新

profile、home patch 与源码文件可能同时变化。如果两个刷新过程各自改 Loader,旧条目的清理和新条目的加载就会交错。当前 @deepseek-ai/dsh-hmr 用 runExclusive() 串行处理配置修改、Include 刷新和模块替换,并拒绝嵌套事务;包管理器的下载与安装留在队列外,成功后才进入配置应用阶段。
配置路径由 HMR 观察 profile 的 bundle 列表、profile patch 和 home patch,等待应用就绪后重读各层,再调用 reconcileProfilePatches() 更新原来的根 Include。它同时等待更新前持有的 Fiber 和当前 Loader:已经移出条目表的插件,也必须完成异步清理。新出现或改变的激活失败会拒绝这次应用,未改变的既有 inactive 条目作为 warning 返回;这不是整棵配置树自动回滚的保证。
源码模块替换采用另一条顺序:先重新导入候选模块,再删除旧注册、等待旧 Fiber 结算,最后注册并等待替代 Fiber。导入失败时恢复模块缓存;替代激活失败时撤销替代实现、恢复缓存,并尝试重新注册旧实现。这里存在旧实现已停止的窗口,恢复本身也可能失败,不能描述成“新代 ACTIVE 后才卸载旧代”的无缝交换。
例如启用一个缺少必需 Service 的插件,配置文件可能已经保存,而应用检查会报告 PENDING;修正依赖或禁用该条目才是后续动作。有 HMR 不等于任意已安装包版本都能热替换:Plugin Manager 对包版本替换仍返回需要重启。没有挂载 HMR 的 profile 则在下次启动读取新配置。
五、用四个问题定位 Cordis 装配故障
- 树是否合对:先用
--dump-config看 id、插入位置和 layer 来源,确认 patch 是否整 config 替换。 - 模块是否解析:检查 profile 模块解析器、安装与 profile 的依赖来源,以及 entry 自己的 base URL。
- 依赖是否满足:查看 Fiber 是否停在 PENDING,以及 service name 的 isolation label 是否一致。
- 生命周期是否收口:检查失败 generation、provider replacement 和 dispose 是否完成,而不是只看插件 callback 是否执行过。
这四层分别对应 composition、resolution、injection 与 ownership。把它们都叫“插件没加载”,排障就会在 YAML、import、service 和资源泄漏之间来回猜。
下一篇会把镜头从插件树切到 SessionEvent:当 loop、工具和 UI 都是可替换插件时,什么才是跨插件共享的运行事实?DSH 的回答是——模型能看见的内容必须先进入可投影的事件日志。
参考源码
- profile-boot.ts 与 profile.ts:bundle/profile/home/CLI patch 层次。
- vendor/include:PatchOptions、expression dialect 与 applyEntryPatches。
- Context、Service 与 Fiber:作用域、能力和生命周期。
- app-boot README:mount、config dump、用户 patch 与激活诊断。
- DSH HMR:统一队列、配置观察、模块卸载与失败恢复。
