读完这篇你能:不改配置文件、不装包、不重启,往一个正在跑的 DSH 进程里挂一截代码,用完摘掉;并且知道挂上去的插件归谁管、怎么确认它真的生效了。
前置:18-把插件打包成配置包。约 30 分钟。
示例代码:dsh-live-greeter、dsh-self-cordis
DSH 版本:0.2.0-rc.2
到 18-把插件打包成配置包为止,我们见过的插件有一个共同点:都是从磁盘上的配置文件读进来的。dsh plugin add 改 package.json,03-用 patch 覆盖 DSH 的默认配置的 patch 改 YAML 行,07-配置与源码的热重载的热重载盯着文件变化——那条链路的起点永远是文件,终点是 Loader 按行 import 一个模块。
Cordis 本身没有这个限制。Loader 只是一个普通插件,它做的事是”读配置,然后对每一行调用 ctx.plugin(...)“。而 ctx.plugin() 是框架 API,任何插件都能调。
所以”运行中挂插件”不是 DSH 新加的能力,是一直就在那儿、只是没人调的东西。这篇把它调起来:挂一个内存里的插件,看它的生命周期归谁管,再收回它。
目标
- 一个插件
dsh-live-greeter,在apply里把一个内存插件挂进运行中的进程;被挂的插件注册一个模型能调的工具。 - 说清
ctx.plugin()返回的是什么、它的状态机怎么走、”挂上了”和”生效了”差在哪。 - 弄清内存插件的清理归属:为什么它不需要谁记得手动清。
- 体验 DSH 已经把这套做成产品的那套工具集,并知道它的边界。
DSH 设计
先交代底盘。DSH 底层是一个叫 Cordis 的插件框架:产品的每一部分都是插件,插件挂进一棵运行中的树。架构文档写明「不存在需要打补丁的特权内核:扩展 dsh 的方式是把插件挂载到其他插件旁边」。既然没有特权内核,挂插件这个动作自然也不是谁的专利。ctx.plugin() 是对任何插件开放的框架能力,平时负责读配置文件的「加载器」(Loader)只是它的第一个调用方:逐行读配置、逐行调用。任何正在运行的插件都可以成为第二个调用方。架构文档还把「进程内直接挂载插件」单列为一种被承认的用法类别,与命令行启动器并列。所以这条路不是钻空子。
每挂一次插件,框架就造一个 fiber 来记账。「fiber」是「一次插件应用」的运行时实例,可以理解成这次挂载的档案夹:它跟踪这次挂载的依赖状态、校验过的配置,以及挂载期间登记的所有清理动作。每个 fiber 有一个全局唯一的编号 uid,卸载后编号作废成 null。它还有一台生命周期状态机:PENDING 是等依赖,LOADING 是启动中,ACTIVE 是真的在提供能力,之后还有 FAILED、UNLOADING、DISPOSED 三档。每次状态迁移都会发出一个框架内部通知 internal/status。所以「挂上了」和「生效了」之间隔着一段可观测的过程,判据是 fiber 的状态,不是挂载调用有没有返回。
挂进什么内容算合法?框架对「插件」的认定很宽:函数、类,或者带 apply 方法的对象,三种形态都合法,传别的会在挂载处直接抛错。挂载时还可以带一份配置。框架先按插件声明的 schema 校验,校验不过就在安定点抛出来。配置错误不会变成「挂上了一半」。
ctx.plugin() 的返回值可以直接 await,因为 fiber 同时是一个可等待对象。不 await,它只是「已登记」;await 它,才是「已安定」——配置或启动出错,会在 await 处抛出来。另一个容易忽略的事实是,框架的注册表按插件的回调函数记账,而不是按插件「种类」。同一个回调可以挂多次,每次产生一个 fiber:
const f1 = ctx.plugin(greeter)
const f2 = ctx.plugin(greeter)
// f1.uid !== f2.uid:同一个 greeter,两份各自独立的挂载
ctx.registry.delete(greeter) // 一次收掉它的全部 fiber
所以 uid 只是「这一次挂载」的编号,不能当插件的长期身份用。要跟踪一个逻辑插件,得用它的回调。
再说等依赖这件事。框架里表达加载顺序的方式只有一种:插件用 inject 声明自己需要哪些服务,然后安静等它们就绪。入门文档的原话是「加载顺序通过服务依赖表达,而非手动编排启动序列」。等不到服务的插件就停在 PENDING。框架语义认为「服务一出现我就激活」是合法状态,所以它不报错、不超时,就停在那里。而且等待不是一次性的门槛:注册表文档对 inject 的说明是「只当所有必需服务可用时才加载」。服务后来消失,依赖它的插件会被卸下;服务再出现,它重新运行。所以排查「挂了但没反应」,第一件事就是看它停在哪个状态、等的是哪个服务。等依赖还有一行简写:ctx.inject(依赖, 回调),等价于挂一个声明了这些依赖的插件。服务齐了就运行回调,任何一个必需服务变化都会卸了重跑。它把「声明依赖、等待、运行」压缩成一行,是运行期代码里最常用的挂载形态。
清理这一头,框架的规矩是「注册是可逆的 effect」:插件挂载期间登记的每个清理函数,都会在它被卸载时按相反顺序执行,是异步的就等它完成。而「挂一个子插件」这个动作本身,框架源码里就是把子 fiber 的卸载函数直接登记成父 fiber 的一笔清理账。推论很直接:父插件卸载时,它挂的子插件自动跟着走,不需要任何人记得手动清理。反过来,在哪个插件的上下文里挂的,子插件的命就长在谁身上。
最后看能力面。入门文档说「上下文是服务的容器」:一个服务占据一个稳定的键,其他插件按键查找,而不是 import 具体实现。运行期挂进来的插件和从配置文件装进来的插件,读的是同一份服务存储。注册工具、提供服务、监听事件,效果与影响力一模一样。「不写配置文件」减掉的只是重启后的存续,不是能力。
这张图同时回答两个常见疑问。「配置装的和运行期挂的差在哪」?只差谁调用、输入是文件路径还是内存对象。「谁来回收运行期挂的插件」?父 fiber 的清理账,不需要人记得。
两条挂载路径
| 静态路径(前 18-把插件打包成配置包) | 动态路径(本篇) | |
|---|---|---|
| 谁调用 | Loader 按配置行 | 你自己的代码 |
| 输入 | 磁盘上的模块路径或包名 | 内存里的一段函数或 { apply } 对象 |
| 存续 | 跟配置文件走,重启后还在 | 只在进程内存里,重启即消失 |
| 谁清理 | Loader 按行 dispose | 持有 fiber 的人 dispose |
一个挂载模型能帮你少踩一半的坑:ctx.plugin(fn) 约等于 element.appendChild(child)。它把一个子节点挂到当前上下文之下,返回一个句柄,句柄的 dispose() 就是 removeChild;而 ctx.registry 约等于 element.children——运行中检查插件树的入口。
fiber 与 registry
ctx.plugin() 的返回值是 fiber:一次插件应用的运行时实例(vendor/cordis/src/registry.ts)。
plugin(plugin: Plugin, config?: any): Fiber & PromiseLike<Fiber>
它同时是个 thenable,这一点后面专门讲。fiber 上有两个东西要认识:
uid 是注册表给的编号,全局唯一,根 fiber 的 uid 是 0。fiber 被 dispose 之后,uid 会变成 null——这是”这个句柄已经死了”的判据。
state 是它的状态机:
registry 是插件注册表,它按插件的回调函数为键,记录这个插件的所有 fiber——同一个回调可以挂多次(每次一个 fiber),所以是「一对多」:
const runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
this._internal.set(callback, runtime)
它同时是运行期检查插件树的入口:keys() / values() / entries() / forEach()。而 registry.delete(plugin) 会把该插件的每一个 fiber 都 dispose 掉再删记录——所以”按插件卸载”是一次收干净,不管你挂了几份。
挂载不等于生效
这是本篇最容易踩的坑,而且它不报错。
ctx.plugin(fn) 返回时,插件只是已登记;要等它安定(依赖都到位、apply 跑完),才是真的在提供能力。中间那个状态就是 PENDING:它在等一个 inject 里写了、但当前没人提供的服务。框架语义认为这是合法的——”服务一出现我就激活”——所以它安安静静地待着,什么都不发生。
三个不同的安定点,值得记住:
| 你做了 | 意味着 |
|---|---|
const fiber = ctx.plugin(fn) |
已登记(可能还停在 PENDING) |
await fiber(它是 thenable) |
已安定(成功了或失败了) |
fiber.state === ACTIVE |
真的在提供能力 |
所以挂一个依赖 tools 的插件时,如果 tools 还没起来,你会看到 fiber 停在 PENDING。判断”到底是谁没起来”,要看服务存储而不是当前上下文的可见性:
ctx.get('tools') // 对:读全局服务存储
ctx.tools // 错:属性代理是拓扑敏感的
ctx.<service> 这个属性代理只对声明过的注入可靠。要判断”全局存储里有没有这个东西”,用 ctx.get(name)。这一条在排查”挂上了但没生效”时是决定性的。
清理的归属
子插件的 dispose 不是谁手动调的——它是父 fiber 的一个 effect。Cordis 里 new Fiber(...) 的构造函数就这么写的(vendor/cordis/src/fiber.ts):
this.dispose = parent.fiber.effect(() => {
const remove = runtime.fibers.push(this)
return async () => {
/* 摘掉自己、必要时删 runtime、卸载 */
}
}, 'ctx.plugin()')
这句话是整篇的枢纽:子插件的生死被登记为父 fiber 的一个 effect。所以父 fiber 卸载时,子插件自动跟着走,不需要谁记得清。05-插件生命周期与 ctx.effect讲的”注册了东西必须交给 ctx.effect“在这里换了张脸:动态挂载最容易犯的错和 05-插件生命周期与 ctx.effect一模一样,区别只是触发点从”Loader 卸载一行”变成”你自己调 dispose()“。
顺带一提,清理是反序执行、而且可以 await 的:ctx.effect(fn, label) 收集 fn 返回的 disposer,卸载时倒着跑,返回 Promise 就等它完成。所以”卸载完成”不等于”回调同步返回了”——真要看,await 卸载。
运行中检查插件树
三条路,各自回答不同的问题:
| 用什么 | 能回答 | 出处 |
|---|---|---|
ctx.registry |
现在到底挂了多少东西(每个回调 + 它的所有 fiber) | vendor/cordis/src/registry.ts |
ctx.loader.locate(fiber) |
这个 fiber 属于配置树的哪一行 | vendor/loader/src/index.ts |
ctx.get('loader')?.store |
配置树还在加载,还是已经安定 | vendor/loader/src/config/tree.ts |
第二条有个好用的性质:内存挂的插件不属于任何行,locate() 对它返回 undefined。这正好是区分两条路径的判据——”这个插件是配置装进来的,还是运行期挂进来的”,问 Loader 就行。
内存插件
demo 的 patch.yaml 里只有配置行:
# dsh-live-greeter 的 bundle 补丁:只有配置行。
# greeter-tool 是被运行期挂上去的,不在任何 patch 行里——
# 所以 dump 出来的配置树里找不到它。
- insert:
- id: live-greeter-mount
name: '/绝对路径/dsh-live-greeter/src/dsh-live-greeter.ts'
配置插件本身只做三件事:
export function apply(ctx: Context): void {
// 返回的是 fiber,同时是 thenable:不 await 只是「已登记」,
// await 它才是「已安定」——两者中间夹着 PENDING 这个状态。
const fiber = ctx.plugin(greeterTool) as Fiber
console.log(`[${name}] mounted uid=${String(fiber.uid)} state=${STATE_NAMES[fiber.state]}`)
ctx.effect(() => {
const off = ctx.on('internal/status', (changed, from) => {
if (changed !== fiber) return
// uid 每次重挂都不一样:这是「真的卸掉重挂了」而不是「复用了同一个 fiber」的证据。
console.log(
`[${name}] uid=${String(fiber.uid)} ${STATE_NAMES[from]} -> ${STATE_NAMES[changed.state]}`,
)
})
return () => {
off()
console.log(`[${name}] unmounting uid=${String(fiber.uid)}`)
}
}, 'live-greeter: observe')
}
(STATE_NAMES 是本篇自己留的一份状态名数组——FiberState 是 const enum,编译后被内联,运行时没有那个对象可查。)
internal/status 是框架内部事件(vendor/cordis/src/fiber.ts),每次 fiber 状态迁移时带 (fiber, 旧状态) 发一次。注意 internal/ 前缀:它不是业务事件(09-订阅 DSH 事件与五种分发模式、10-事件进阶讲的那套),是框架自己的状态通知,用来观测最直接。
被挂的内存插件就是普通的插件写法——name、inject、apply 一个不少:
export const name = 'live-greeter'
/** 要工具注册表。没有它,这个 fiber 会停在 PENDING——而且不报错。 */
export const inject = ['tools']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'live_greet',
description: '跟一个人打招呼。由 dsh-live-greeter 在运行期挂上。',
parameters: {
whom: { type: 'string', required: true, description: '要打招呼的人。' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: { whom: { type: 'string', required: true } },
},
render: (_args, value) => [{ type: 'text', text: `你好,${value.whom}。` }],
},
execute(args) {
return Promise.resolve({ whom: args.whom })
},
}))
}
ctx.plugin(greeterTool) 传的是模块命名空间对象,它上面有 apply,所以合法——ctx.plugin 接受函数、类,或带 apply 的对象;传别的会抛 invalid plugin, expect function or object with an "apply" method, received …。
影响范围
内存插件挂在同一个进程里,用的是同一份服务存储。所以它能 ctx.tools.register 一个新工具(所有会话立刻看得到),也能 provide 一个服务(别的会话读得到)。这不是缺陷,是”同进程”这个词的直接后果。
想收窄影响面,DSH 有作用域机制(dsh-scope):
const scope = createScope(ctx, 'my-key')
// scope.ctx 上挂的东西只在这条作用域链里可见
scope.dispose() // 异步,等它完成
createScope 内部也是 ctx.plugin(scope)——所以作用域同样是一个插件,同样受父 fiber 的 effect 管。本篇只用它说明”影响面可以收窄”,完整的注册表层机制够单独一篇。
运行期工具集
上面那套是你自己写代码挂插件。packages/extensions/ 是 DSH 把「运行期组合」做成产品的那一组包:进程内的动态定义、运行时的 API 查询、以及浏览器那一半。
四个包各管一段:
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-tool-cordis |
两个只读检查工具,不贡献系统提示 |
@deepseek-ai/dsh-cordis-host-runner |
定义注册表 + node:vm 沙箱 + Host 半生命周期 + 检查注册表(提供 ctx.dynamicCordisRunner 与 ctx.cordisInspect) |
@deepseek-ai/dsh-cordis-client-runner |
浏览器半的加载与应答 |
@deepseek-ai/dsh-client-ui-cordis |
面板与卡片(人在上面批准、启停) |
模型可见的只有那两个只读工具:
cordis_inspect_list:列出当前已知的检查提供方——平台、用途、只读方法与它们的输入输出 schema,查询前先拿它选 provider 与 method。cordis_inspect_query:按选中的 provider 与 method 去读真实约定:服务方法、事件模式、插件 Config schema、工具 schema、实时 slot 树。
它们不能调用业务方法,也不能改运行时。
创建和运行动态定义没有模型工具。define / run / stop / undefine 是 ctx.dynamicCordisRunner 上的服务动词,给程序调用方和浏览器面板用;模型要持久化地装插件,走的是 Plugin Manager 那条路(@deepseek-ai/dsh-plugin-manager/tools 那行工具)。定义本身仍然是不可变的包版本:define 只做参数与语法校验、不执行任何代码,mode: "run" 启动当前版本,mode: "update" 换到新版本——出错之后是”追加一个新版本再切过去”,不是”改掉旧版本”。
demo 里那个 dsh-self-cordis 就是一条安装命令:它自己是组合包,cordis.patch.yml 里插三行,把检查工具集的三块挂上。
- insert:
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
config:
# 沙箱只对动态包函数体的同步部分计时,异步部分逃得出去。
vmTimeoutMs: 5000
# Host 检查提供方按进程注册一次:注册表拒绝重复 id,
# 所以挨着 runner 挂一遍,上面的两个工具就读得到。
- id: cordis-inspect-providers
name: '@deepseek-ai/dsh-tool-cordis/host'
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'
这里正好接上 18-把插件打包成配置包的一个推论:dsh-tool-cordis 和它的 runner 都不是组合包(manifest 里没有 dsh 字段),所以 dsh plugin add 对它们只会打印一条 declares no dsh.bundle 的警告——它们是依赖,配置得靠某一层 patch 显式写行。
装之前请把边界读完。 官方 README 的措辞是”对待动态包要像对待 bash 访问一样刻意”:
- 沙箱是给老实代码用的围栏,不是安全边界。
vmTimeoutMs只围得住函数体的同步部分;异步的代码逃得出去。沙箱给动态包的是一层不含框架内部机制的 façade,但动态包声明的服务照样触及存活运行时,从而影响同一进程里的其他会话。 - 定义是会话级、进程本地的。 只有定义它的会话看得见、管得着;其他会话读作不存在。重启即消失,会话日志里只留下 define 的参数与回执,注册表不会从日志里恢复。
- 带浏览器半的包要人批准,而且会一直等。
run对这类包返回status: 'awaiting-approval'并挂起,由页面依次走 host 半、client 半才结算;没有页面连着的部署会一直挂到提问的那个轮次被取消。纯 Host 的包不受影响。
验证
demo A 的装法照 18-把插件打包成配置包:
cd dsh_plugin/dsh-live-greeter && ./install.sh
然后看三个概念各自对应的现象:
# 1. 配置树里只有配置行——内存插件不在配置树里
dsh --profile test --dump-config | grep -c 'live-greeter-mount' # 1
dsh --profile test --dump-config | grep -c 'greeter-tool' # 0
# 2. 起一个会话,看启动日志里的 mounted 那一行
dsh --profile headless "用 live_greet 跟 小王 打个招呼"
日志里应当是 mounted uid=… state=ACTIVE。如果打出来的是 PENDING,去看启动审计里那行 pending——它会写清楚等的是哪个服务(waiting for service: …)。
# 3. 改一行 patch 触发用户层热重载,观察 unmount 与 uid 变化
会看到 unmounting uid=…,然后是新的 mounted uid=…,两个 uid 不一样。这是”真的卸掉重挂了”而不是”复用了同一个 fiber”的肉眼证据(注册表的计数器单调递增)。
demo B 的验证是让模型走一遍:
cd dsh_plugin/dsh-self-cordis && ./install.sh
先确认三行都进了配置树:
dsh --profile test --dump-config | grep -A2 cordis-host-runner
dsh --profile test --dump-config | grep -c cordis-inspect-providers # 1
然后起会话:
cordis_inspect_list → 看有哪些检查提供方(Host 的 Config 提供方应当在里面)
cordis_inspect_query → 挑一个 provider 与 method,读它的方法签名或 slot 树
想体验动态定义就去浏览器面板:在那里定义一个包并运行——带浏览器半的会先要你批准,纯 Host 的直接在本进程激活。最后重启 dsh,面板里就空了:定义只在进程内存里,会话日志不会把它恢复回来。
注意事项
- 「挂上了」和「生效了」之间夹着
PENDING,而且它不报错。 动态挂载最常见的失败现象就是”什么都没发生”。判据是fiber.state,不是ctx.plugin()有没有返回。 - 用
ctx.get(name)查可选服务,不要用ctx.tools这种属性代理。 属性代理是拓扑敏感的——它回答的是”我这个位置能不能看见”,而你要问的往往是”全局存储里有没有”。这两者在动态挂载的场景里经常不一致。 - uid 不是插件的身份。 每次重挂都是新 uid(旧的那个 dispose 后变成
null)。要长期跟踪一个逻辑插件,用registry里的回调函数做键,别用 uid。 - 内存插件写不回配置文件。 它是运行期的产物:重启就没了,
--dump-config里也没有它。要留下来的东西,得走 18-把插件打包成配置包那条路——落进某个包的 patch 或源码。 - “不写文件”不等于”没影响”。 内存插件照样能注册工具、提供全局服务、占用事件。它在当前进程里的影响力,和从配置装进来的插件没有区别。
- 注销时看清自己站在哪。 在插件 A 的
apply里挂的插件,生命跟着 A 的 fiber;在会话上下文里挂的,跟着那条会话的 fiber。registry.delete(callback)则是”把这个回调的所有 fiber 一起收掉”,适合做兜底清理。