第 02 章 Cordis 内核的 2696 行
代码基线:commit
8f86b979a1(2026-10-01)|0.2.0-rc.1| 本系列讲次:M1
本章导读
这一章要把"一切皆插件"这句话,落到 2696 行具体代码上。
vendor/cordis/src/ 一共九个文件、2696 行。就这么点代码,组织起了 328 个包、35.6 万行产品代码。这个比例是本仓库最值得注意的一件事,所以本章逐文件读它:Service 基类 115 行、Context 146 行、事件总线 352 行、注册表 337 行、Fiber 754 行。
读完你应该能回答三个问题:ctx.foo 到底发生了什么?「加载顺序由依赖决定」是用什么机制实现的?waterfall 和 serial 差在哪、为什么 AGENTS.md 单独为 waterfall 立了一条铁律?
本章会推翻一个很常见的想象。你 book 里那篇《第三章 Cordis 内核》说"启动顺序由依赖声明决定",结论对,机制猜错了——它设想了一个调度器。真实实现是 fiber.ts:611-639 里的一串字符串:每个插件算出一串"我依赖的服务分别由哪个 fiber 提供",服务换人了这串就变了,于是自动卸载再加载。没有队列,没有调度器,只有状态机在比较字符串。
学习要点
- Cordis 内核的九行文件分布,先记住位置再读内容
- epoch 字符串是依赖驱动加载的真正实现,也是热插拔不重启的底层
- 派发模式是五种不是四种(多一个同步版
bail)ToolGuard之所以单调,是因为它没有 allow 返回值——能用类型表达的不变量就别用文档表达- 事件分发的上下文过滤,是后面所有 agent scope 机制的地基
本章目标
把"Cordis 有五个观念"从记忆变成能指着源码行号复述。读完你应该能回答:
ctx.foo到底发生了什么?(答案在reflect.ts:136-171,不在context.ts)- "加载顺序由依赖决定"是用什么机制实现的?(不是调度器,是
fiber.ts:611-639的 epoch 字符串) - waterfall 和 serial 差在哪?为什么 AGENTS.md 单独为 waterfall 立了一条铁律?
一、地图:vendor/cordis/src/ 全部内容
fiber.ts 754 插件的一生:状态机 + 可逆效果 + epoch
reflect.ts 418 ctx 代理的 get/set/has 陷阱、服务存储、notify、mixin
events.ts 352 事件总线:五种派发模式 + 作用域过滤
registry.ts 337 插件注册表、inject 解析、@Inject 装饰器
utils.ts 287 DisposableList / symbols / createCallable
logger.ts 271 日志
context.ts 146 interface Context + class Context(两者并存)
service.ts 115 Service 基类
index.ts 16 导出面
───
2696
书稿第二章说"Cordis 是两万行内核"这个印象是对的(加上 loader / include / hmr / schemastery / cosmokit 一共约 6000 行)。但真正值得注意的数字是另一面:dsh 用 2696 行的内核,组织起了 328 个包、35.6 万行产品代码。
二、service.ts(115 行)——knowly 说它"极简",这是错的
knowly 那篇的判断是:"Service 基类只提供'绑定到上下文'这一个核心功能,没有抽象方法、没有必须实现的回调列表"。
前半句对,后半句严重低估。 实际有四块知料:
2.1 构造函数只做三件事(service.ts:42-59)
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
if (self[symbols.invoke]) {
self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
}
self.ctx = ctx
self.name = name
self.ctx.reflect.provide(name, self, this[symbols.check])
return self // ← 返回另一个对象!构造函数可以换掉 this
}
两个非平凡点:
- 构造函数可以返回另一个对象(第 58 行)。带
[symbols.invoke]的服务(比如可调用的ctx.logger())会被createCallable包成函数对象,此时this已经不是原来的实例了。 - 注册是
reflect.provide的效果,所以服务随 fiber 卸载自动消失(第 57 行注释明说)。
2.2 [symbols.resolveConfig](service.ts:86-102)——knowly 完全没提
这是"拦截配置"真正落地的地方:
[symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept]
const configs: any[] = []
while (this.name in intercept) { // 沿原型链往上走
if (Object.hasOwn(intercept, this.name)) configs.unshift(intercept[this.name])
intercept = Object.getPrototypeOf(intercept)
}
if (base) configs.unshift(base)
if (head) configs.push(head)
return this['Config']?.merge
? this['Config'].merge(...configs)
: Object.assign({}, ...configs)
}
语义:同一个服务实例,在不同消费者眼里可以带着不同的配置。base 最低优先级、head 最高,靠近 root 的祖先先应用。
这就是 knowly 那篇里说的"每个消费者配置",但它写成了
Inject 装饰器支持配置拦截——准确的位置是这里,装饰器只是入口。
2.3 [symbols.filter](service.ts:61-63)
protected [symbols.filter](ctx: Context) {
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}
一行,但它是 isolate 语义的判据:两个同名服务是否"同一个",就看它们的 isolate 标签是否相等。
2.4 static [Symbol.hasInstance](service.ts:104-114)
替换了原生 instanceof,沿原型链手写循环。目的是跨 realm / 跨 cordis 副本也能识别为同一个 Service 类。context.ts:61-68 的 Context.is 用的是同一手法(Symbol.for('cordis.is') 全局品牌)。
三、context.ts(146 行)——knowly 说"Context 其实是一个接口,不是一个完整的类"
这句话是错的。 源码里 interface Context(第 16-33 行)和 class Context(第 42 行)同时存在,靠 TypeScript 声明合并共存:
interface那一半描述"可以从ctx读到哪些属性",并被所有核心服务和插件通过declare module './context.ts'扩展——所以ctx.tools、ctx.llm这些属性不是某个包里定义的,是全仓库往这一个 interface 上合并出来的。class那一半是运行时实现,constructor(第 71-84 行)装好五个内置服务,然后return self——一个 Proxy:
const self = new Proxy<this>(this, ReflectService.handler)
教学点:context.ts 只负责"造出一个被代理的容器",代理的逻辑全在 reflect.ts。knowly 那篇把 ctx.tools 的解析说成"context.ts 的 Proxy get 拦截器按服务名查找"——方向反了。
3.1 extend / isolate / intercept 三个原语
| 方法 | 行 | 做什么 |
|---|---|---|
extend(meta) |
99-107 | Object.create(getTraceable(this, this)) + 覆盖 meta 里的自有属性。父上下文不被修改 |
isolate(name, label?) |
121-125 | 克隆 isolate 映射,把 name 指向新标签(默认 Symbol(name))。同名服务从此互不可见 |
intercept(name, config) |
141-145 | 克隆 intercept 映射,追加一条服务级配置。它不改服务行为,只改配置解析结果 |
knowly 那篇把 intercept 说成"在查找路径上加一层拦截配置,比如给所有插件注入的 HTTP 客户端统一加一个代理设置"——"统一加工"这个描述会误导。真实语义是"这一层之下的消费者,各自合并到一份配置",配合 2.2 的
resolveConfig使用。
四、events.ts(352 行)——五种派发模式,不是四种
4.1 勘误:knowly 列了四种,漏了 bail
第 32 行的类型定义写得很清楚:
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
| 模式 | 异步? | 返回值 | 实现在 | 语义 |
|---|---|---|---|---|
emit |
否 | void | 194-196 | 同步触发,不等返回值 |
parallel |
是 | void(失败抛 AggregateError) |
183-187 | Promise.allSettled,全部落定才继续 |
serial |
是 | 第一个 bail 值 | 204-209 | 逐个 await,遇到 bail 立刻停 |
bail |
否 | 第一个 bail 值 | 217-222 | 同步版 serial |
waterfall |
否 | 最外层监听器的返回值 | 234-243 | 每个监听器包住剩余链条 |
bail 和 serial 是一对,只差 async。 这是最容易混的一处,也是最该记住的一处——因为 ctx.on() 自己就用 bail 派发 internal/listener(第 296 行)来让监听器注册可被拦截。
4.2 isBailed:只有三个值不算 bail
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
0 和 '' 都算 bail(实测已验证)。这意味着一个监听器 return 0 会截断 serial 链——这是真实存在的坑。
4.3 最重要的遗漏:上下文过滤(events.ts:165-175)
dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
if (!name.startsWith('internal/')) {
this.emit('internal/dispatch', type, name, args, thisArg)
}
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
}
这是 dsh agent scope 的地基。 当派发带一个 thisArg(一个 Context)时,只有 isolate 标签匹配的监听器才会被调用——除非监听器声明了 { global: true }。
对照 docs/glossary.md 的词条:
- scope carrier = 这里传入的
thisArg - scoped dispatch = 第 171-173 行的过滤规则
- "Registry-subject events may remain deliberately unfiltered" =
{ global: true }(events.ts:116)
knowly 那篇一个字都没提这一层。 而它恰恰是理解"dsh 的 subagent 为什么只能看见自己的工具"的钥匙。
4.4 on() 的两道防线(events.ts:288-302)
this.ctx.fiber.assertActive() // ① fiber 已卸载就抛 INACTIVE_EFFECT
listener = this.ctx.reflect.bind(listener) // ② 监听器被 trace 包装
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result // ③ 可被拦截:返回非空就替换注册
internal/dispatch(第 169 行)则在每个非 internal 事件派发前触发一次——这就是你 cordis preset 里 cordis_inspect_* 工具的数据来源。
五、registry.ts(337 行)——knowly 的核心判断是对的
knowly 那段"判断标准只有一个:这个对象有没有 apply 方法"——完全正确,源码第 8-10 行:
function isApplicable(object: Plugin) {
return object && typeof object === 'object' && typeof object.apply === 'function'
}
但完整形状是三种(第 92-95 行):函数、类、{ apply } 对象。resolve()(第 222-228 行)先判 typeof plugin === 'function'——函数插件优先级最高,一个既是函数又有 apply 的对象按函数处理。
5.1 Plugin.Base 的五个字段(registry.ts:100-111)
interface Base<T = any> {
name?: string // 诊断显示名
Config?: StandardSchemaV1<any, T> // standard-schema 校验器
inject?: Inject // 需要哪些服务
provide?: string | string[] // 提供哪些服务名
intercept?: Dict<boolean> // ← knowly 没提
}
intercept 声明"我消费哪些服务的 intercept 配置",配合 Service[symbols.resolveConfig] 使用。
5.2 ctx.plugin() 返回的是可 await 的 Fiber
const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>
wrapped.then = (onFulfilled, onRejected) => fiber.await().then(onFulfilled, onRejected)
await ctx.plugin(...) 等的是加载落定,失败时抛配置校验或启动错误(fiber.ts:704-710)。这就是上面动手脚本第 5 条能 await gated 的原因。
5.3 @Inject 装饰器的两种用法(registry.ts:37-60)
- 打在类上 → 汇入静态
inject映射 - 打在方法上 → 推迟到声明的服务可用后才调用该方法
第三种情况直接抛错:'@Inject() can only be used on class or class methods'。
六、fiber.ts(754 行)——核心是 epoch,不是"逐个调用移除方法"
6.1 六个状态(fiber.ts:147-154)
PENDING 等待所需服务
LOADING 插件回调正在运行
ACTIVE 已加载并对外提供
FAILED 回调或配置抛了
DISPOSED 已被移除,不能重启
UNLOADING 清理器正在运行
6.2 epoch:依赖驱动加载的真正实现
knowly 那篇写"启动顺序由依赖声明决定"——结论对,但它把机制想象成了一个调度器。真实机制是一个字符串:
_refresh() { // fiber.ts:611
let epoch = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break } // 有任何一个依赖缺席 → INACTIVE
epoch += ':' + impl.fiber.uid // 否则把每个依赖方的 fiber uid 串起来
}
this._setEpoch(epoch)
}
_setEpoch(epoch: string) { // fiber.ts:625
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return // 没变,什么都不做
this._runner.epoch = epoch
if (this.inertia) return // 正在过渡中,交给当前过渡处理
this._updateState(() => {
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
this.inertia = this._reload(); return FiberState.LOADING
} else {
this.inertia = this._unload(); return FiberState.UNLOADING
}
})
}
一句话:每个插件算出一串"我依赖的那些服务分别由哪个 fiber 提供",服务换人了这串就变了,fiber 自动卸载再加载。没有队列,没有调度器,只有一个状态机在比较字符串。
这个设计的直接好处:某个 Provider 被 patch 掉(换个 fiber 提供同名服务),所有依赖它的插件自动全部卸载;patch 撤销,它们自动全部重新加载。这正是书稿 §1.5 说的"热插拔不重启"的底层机制,也是 M0 里 HMR 能工作的原因。
6.3 Effect 的五种形态(fiber.ts:83-93)
type Effect<T> = SyncEffect<T> | AsyncEffect<T>
type SyncEffect<T> = Disposable<T> | Iterable<Disposable<T>>
type AsyncEffect<T> = Promise<Disposable<T>> | AsyncIterable<Disposable<T>>
_execute(fiber.ts:356-400)逐个分派,生成器分支是真实在用的——reflect.ts:366 的 mixin() 就是 this.ctx.fiber.effect(function* () {...})。
6.4 清理的两条规则
- 单个 effect 内部:严格逆序(
fiber.ts:431,disposables.splice(0).reverse())。实测A B gen(G1,G2)→G2 G1 B A - 整个 fiber 卸载:并发(
fiber.ts:676,Promise.all(...)),每个 disposer 各自 try/catch,单个失败只记日志不影响同伴(fiber.ts:683-685)
6.5 配置校验发生在 waterfall 之后
private _resolveConfig(config: any) { // fiber.ts:641
config = this.context.waterfall(this, 'internal/config', config, () => config) // ← 先过 waterfall
return this.runtime ? resolveConfig(this.runtime, config) : config // ← 再校验
}
resolveConfig(fiber.ts:50-62)用 standard-schema,并且明确拒绝异步校验(throw new TypeError('Async config validation is not supported'))。
knowly 勘误:那篇写"失败的配置抛出带行号的 ValidationError"。没有行号。
ValidationError构造器(fiber.ts:27-35)格式化的是- {message} (at {path.join('.')})——是配置路径,不是行号。
6.6 错误纪律(fiber.ts:287-295 的注释值得完整读)
this.inertiaitself should never reject — both_reloadand_unloadswallow their own work errors viactx.logger.error. If it does reject, the only remaining cause is the logger itself failing... Let the rejection propagate; process-level crash is the honest outcome.
七、勘误:knowly《第三章 Cordis 内核》四处错误
| # | 原文 | 实际 |
|---|---|---|
| C-01 | "Context 其实是一个接口(interface),不是一个完整的类" | 两者并存:interface Context(16-33)+ class Context(42)。接口那一半是被全仓库声明合并扩展的扩展点 |
| C-02 | "当你写 ctx.tools 时……触发一个代理的 get 拦截器,它按服务名查找已注册的服务并返回"(归给 context.ts) |
代理逻辑在 reflect.ts:136-171;context.ts 只负责造出这个代理 |
| C-03 | "Cordis 的事件总线支持四种派发模式:emit/waterfall/parallel/serial" | 五种,多一个 bail(同步版 serial),且 ctx.on() 内部就在用它 |
| C-04 | "失败的配置抛出带行号的 ValidationError" | 带的是配置路径(path.join('.')),不是行号 |
两处重大遗漏(不是错,是缺口):
| # | 缺口 | 为什么重要 |
|---|---|---|
| M-01 | 完全没提上下文过滤(events.ts:171-173) |
这是 dsh agent scope 的实现基础;不理解它就看不懂 subagent 为什么看不见父 agent 的工具 |
| M-02 | 没提 epoch 机制,把"依赖决定启动顺序"讲成了调度 | 真实机制是 fiber.ts:611-639 的字符串状态机。理解它才能理解 HMR 为什么能批量重载 |
仍然准确、可以直接复用的部分:五个观念的划分、四种(应为五种)派发模式的语义描述、isolate 与 intercept 的区分思路、"插件生命周期的每一步都对应一个可撤销的注册"、"配置文件顺序只是审美问题"。
八、更重要的发现:dsh 用的不是上游 Cordis
vendor/README.md 记了 22 条本地改动,其中直接改 Cordis 内核的有四条:
| # | 改动 | 为什么重要 |
|---|---|---|
| 6 | cordis/src/fiber.ts 生命周期加固:effect 的 wrapper 在 setup 体运行之前就挂上所有权列表;setup 同步失败回滚已收集的清理;UNLOADING 期间拒绝创建 effect(PENDING/LOADING 仍合法);子 fiber 在 internal/plugin 发布前就拿到父方持有的 disposer |
**闭环了你的 开发大坑_dshmarket重复挂载**那类"卸载期注册逃逸"问题 |
| 11 | include/src/index.ts 提取出 applyEntryPatches 和 entryListSchema 两个导出 |
**这就是 dsh --dump-config能存在的原因**——配置工具复用同一套 patch 算法,绝不重写。同时修了一个上游 bug:原先insert` 进来的行无法被同一 patch 列表里的后续 patch 配置 |
| 15 | 惰性配置解析(移植 cordiverse/cordis#41):保留 fiber 原始配置,等注入的服务激活后才通过 internal/config 解析 |
这就是 M0 里那些 disabled: !!js '!ctx.get(''profileContext'')' 的来历——它们是延迟求值的表达式,不是加载时算一次的布尔 |
| 19 | Loader 按"拥有哪个 module-job API"识别 Node 版本,而不是按大版本号 | 上游把 24.0–24.11.1 误判为 v2,导致 dsh web 服务出一个空的客户端图——这是一个真实的线上 bug 修复 |
教学点:读 vendored 代码时必须先读
vendor/README.md的 "Local modifications"。否则你会把 DSH 的加固当成 Cordis 原生行为,也会把上游 bug 当成设计。
本章实验(附录)
九、动手验证(七条,本机全部通过)
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ygsdoc/学习笔记/labs/M1-cordis-lab.ts"
实测输出:
1) 注册后 : A↑ B↑
效果标签树 : ["A","B","gen"]
dispose 后 : A↑ B↑ G2↓ G1↓ B↓ A↓ ← 严格逆序:G2 G1 B A
2) isBailed : null=false false=false undefined=false 0=true ''=true ← 0 和空串都算 bail
3) waterfall : 返回=undefined 链路=VETO(没调next)
4) isolate : root 消费者=A-value | child 消费者=B-value | root 再问=A-value
5) 依赖缺失 : started=false state=0 uid=1 ← PENDING,不报错也不启动
6) 服务补齐 : started=true state=2 ← 自动 LOADING→ACTIVE
7) 只声明命名空间服务: state=2 ← 已激活(容器座位 remote 没声明,activate 不检查它)
实际读取时报 : cannot get property "remote" without inject
第 7 条值得单独说:它精确复现了你 开发大坑_cordis只写命名空间服务漏了容器座位 里记录的现场——插件 inject 只声明了 remote.workspacePublish,激活成功(state=2 即 ACTIVE),但一点就崩。
为什么激活时不报错? 因为 inject 门控只检查"我声明的那些名字在不在"(fiber.ts:611-623),而 ctx.remote 是一次属性读取(reflect.ts:136-171),走的是另一条路径。声明和读取是两道独立的关卡。
M1 通过标准:能解释清楚"为什么插件能激活成功却一用就崩"——即 inject 门控与属性读取走的是 fiber.ts:_refresh 和 reflect.ts:handler.get 两条独立路径。
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "dsh-源码精读/labs/M1-cordis-lab.ts"
预期输出(本机实测,任何一行对不上就说明基线变了):
1) 注册后 : A↑ B↑
效果标签树 : ["A","B","gen"]
dispose 后 : A↑ B↑ G2↓ G1↓ B↓ A↓ ← 严格逆序:G2 G1 B A
2) isBailed : null=false false=false undefined=false 0=true ''=true ← 0 和空串都算 bail
3) waterfall : 返回=undefined 链路=VETO(没调next)
4) isolate : root 消费者=A-value | child 消费者=B-value | root 再问=A-value
5) 依赖缺失 : started=false state=0 uid=1 ← PENDING,不报错也不启动
6) 服务补齐 : started=true state=2 ← 自动 LOADING→ACTIVE
7) 只声明命名空间服务: state=2 ← 已激活(容器座位 remote 没声明,activate 不检查它)
实际读取时报 : cannot get property "remote" without inject
本章的勘误条目见 附录 A · 勘误总表。