第 1 章
一切皆插件:Cordis 的五个思想
场景还原
假设你接手了一个需求:给 DeepSeek Harness 加一条规则,模型每次执行 Bash 命令前,先检查命令里有没有访问 /etc/passwd,有就拦下来。
在 Claude Code 里,这件事的官方答案是一个 hooks 脚本:你写一个 Python 文件,在 settings.json 里声明它匹配 PreToolUse 的 Bash 事件,脚本从 stdin 读 JSON、打印到 stderr、用 exit code 表达「放行」或「拦截」。每一次工具调用,harness 都要 fork 一个进程来跑你的脚本。
在 dsh 里,同样一件事的答案是注册一个监听器:你写一个插件,在 tools/pre-execute 这个事件上挂一个函数,函数里检查命令、决定要不要调用 next()。监听器跑在 harness 自己的进程里,它可以是任意 TS 代码,可以持有状态,也可以随时卸载。
这两条路背后是同一个问题:一个 agent 运行时,把「改一行行为」这件事开放到什么程度。dsh 的答案激进得多:模型适配器、工具注册表、会话日志、agent 主循环,全都是插件,全部可以从配置里换掉。这个答案的全部基础设施,落在 vendor/cordis 这个 2693 行的框架里。本章把它读透。
逐行精读
Context:一个被代理的服务仓库
Cordis 的核心是一个 Context 对象。先看它的公开形状:
16export interface Context {17 /** Isolation map: service name → scope label. Lookups for a name resolve within its label. */18 [symbols.isolate]: Dict<symbol>19 /** Intercept map: service name → config merged into that service's per-plugin config. */20 [symbols.intercept]: Dict21 /** The root context of the application (every child context shares it). @experimental */22 root: this23 /** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */24 baseUrl?: string25 /** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */26 events: EventsService27 /** The logging service. Call `ctx.logger(name)` for a named logger. */28 logger: LoggerService29 /** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */30 reflect: ReflectService31 /** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */32 registry: RegistryService33}一个 Context 身上长着四样东西:事件总线(events)、日志(logger)、反射层(reflect,管服务读写)、插件注册表(registry)。「Mixed onto ctx」这个说法值得注意:ctx.on、ctx.plugin 这些方法并不属于 Context 类本身,是这四样服务把自己的方法挂到了 ctx 上。这意味着事件总线自己也是一个服务,理论上可以被替换。
再看 Context 类怎么构造这四样服务:
42export class Context {43 /** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */44 static readonly effect: unique symbol = symbols.effect45 /** Symbol key for a context's listener filter, consulted on every event dispatch. */46 static readonly filter: unique symbol = symbols.filter47 /** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */48 static readonly isolate: unique symbol = symbols.isolate49 /** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */50 static readonly intercept: unique symbol = symbols.intercept⋯ // ... static is() 与其他辅助 ...70 /** Create the root context and install the built-in services. */71 constructor() {72 this[symbols.isolate] = Object.create(null)73 this[symbols.intercept] = Object.create(null)74 const self = new Proxy<this>(this, ReflectService.handler)75 this.root = self76 this.baseUrl = undefined77 this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])78 this.reflect = new ReflectService(self)79 this.registry = new RegistryService(self)80 this.events = new EventsService(self)81 this.logger = new LoggerService(self)82 this.fiber._disposables.clear()83 return self84 }两件事。第一,第 74 行 new Proxy(this, ReflectService.handler):真正的 ctx 是一个 Proxy,所有属性读都会经过反射层的 handler。所以 ctx.tools 这种读法,不是读一个普通属性,是向服务仓库发起一次查找。第二,第 77 行先建了一个 Fiber,再建四个服务。Fiber 是「纤维」,Cordis 里每个插件都跑在一根 fiber 上,fiber 负责收集这个插件注册的所有效果(effect),卸载时统一回收。四个服务建完,第 82 行把 fiber 收集的内部 disposable 清空,相当于内建服务的生命周期不挂在任何插件上,它们与根 context 同生共死。
Service:注册即上架
第二个核心概念是 Service。dsh 里每个能力(会话、工具、LLM 适配)都是一个 Service,挂在 ctx 的某个名字下:
11export abstract class Service<out T = never> {12 /** Symbol key of an instance method run after construction (class plugins). */13 static readonly init: unique symbol = symbols.init14 /** Symbol key of the availability predicate passed to `ctx.provide()`. */15 static readonly check: unique symbol = symbols.check16 /** Symbol key of the phantom intercept-config type parameter. */17 static readonly config: unique symbol = symbols.config18 /** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */19 static readonly invoke: unique symbol = symbols.invoke20 /** Symbol key of the helper deriving an extended service instance. */21 static readonly extend: unique symbol = symbols.extend22 /** Symbol key of the tracker metadata used for context tracing. */23 static readonly tracker: unique symbol = symbols.tracker24 /** Symbol key of the intercept-config resolution helper below. */25 static readonly resolveConfig: unique symbol = symbols.resolveConfig2627 declare [symbols.config]: T2829 /** The service name this instance is registered under. */30 public name!: string3132 /**33 * Register this instance as `name` in the current context.34 *35 * Calls `ctx.reflect.provide(name, this, this[Service.check])`, so the36 * service is unregistered automatically when the owning fiber unloads.37 * Services with a `[Service.invoke]` body return a callable instance.38 *39 * @param ctx — the context to register in (stored as `this.ctx`).40 * @param name — the service name; defaults to the static `provide` field.41 */42 constructor(protected ctx: Context, name: string) {43 name ??= this.constructor['provide'] as string4445 let self = this46 const tracker: Tracker = {47 associate: name,48 property: 'ctx',49 }50 if (self[symbols.invoke]) {51 self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)52 }53 self.ctx = ctx54 self.name = name55 defineProperty(self, symbols.tracker, tracker)5657 self.ctx.reflect.provide(name, self, this[symbols.check])58 return self59 }Service 的子类在构造函数里调 super(ctx, name),第 57 行 reflect.provide(name, self, ...) 就把它注册到了 ctx 上。注册动作发生在构造时,卸载不靠手写代码:类注释(service.ts 第 45 至 46 行)写明,服务「is unregistered automatically when the owning fiber unloads」。注册与卸载天然对称,这就是后面「注册即效果」的类级实现。
另外注意第 43 行 name ??= this.constructor['provide']:服务的名字来自静态 provide 字段。这个字段是插件元数据的一部分,看 registry 里的定义:
100 export interface Base<T = any> {101 /** Display name used for fiber diagnostics and logger names. */102 name?: string103 /** Standard-schema validator applied to config before the plugin starts. */104 Config?: StandardSchemaV1<any, T>105 /** Services the plugin requires; it only loads while all are available. */106 inject?: Inject107 /** Service name(s) the plugin provides (read by `Service` and by loaders). */108 provide?: string | string[]109 /** Service names whose intercept config the plugin declares it consumes. */110 intercept?: Dict<boolean>111 }第 105 至 106 行注释是加载顺序问题的答案:插件声明 inject 依赖后,「it only loads while all are available」,只在其依赖的服务全部就位后才加载。加载顺序不是靠启动脚本手工排出来的,是靠依赖声明推出来的。声明了 ctx.llm 的插件,自然会等到 LLM 服务上架。
effect:可逆注册的原语
事件监听器的注册,走的是 fiber 上的 effect():
402 /**403 * Register a cleanup-aware effect on this fiber.404 *405 * `execute` runs immediately; the disposers it produces are collected and406 * run (in reverse order) either when the returned disposer is called or407 * when the fiber unloads, whichever comes first. Calling the disposer twice408 * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is409 * already disposed, and `TypeError` if `execute` returns an invalid shape.410 *411 * @param execute — the effect body; see {@link Effect} for accepted shapes.412 * @param label — effect label shown in `getEffects()` diagnostics.413 * @returns a disposer that tears the effect down and settles once done.414 */415 effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>416 /** Same as above for async effects; the disposer is also awaitable. */417 effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>418 effect(execute: () => Effect, label = 'anonymous'): any {419 this.assertActive()420 if (this.state === FiberState.UNLOADING) {421 throw new CordisError('INACTIVE_EFFECT')422 }423424 const disposables: Disposable[] = []425 let disposing = false426 let disposalTask: void | Promise<void>427 const dispose = () => {428 if (disposing) return disposalTask429 disposing = true430 let task!: void | Promise<void>431 for (const disposable of disposables.splice(0).reverse()) {432 if (task) {433 task = task.then(() => runDisposable(disposable))434 } else {435 const result = runDisposable(disposable)436 if (isObject(result) && 'then' in result) {437 task = result as any438 }439 }440 }441 return disposalTask = task442 }effect() 的语义浓缩成三行:传入的函数立即执行;函数里调用的每一个 ctx.on() 返回的 disposer 都被收集进 disposables 数组;卸载(或手动调 disposer)时按逆序逐个执行清理。第 420 至 422 行是防御:如果 fiber 正在卸载,再注册效果直接抛 INACTIVE_EFFECT。第 427 至 428 行保证重复 dispose 是 no-op:第二次调用拿到的是同一个 disposalTask。
「立即执行 + 逆序回收」是理解整个 dsh 行为的关键。一个插件挂载时,它写的所有注册代码立刻生效;卸载时,这些注册按注册的逆序全部撤销。中间不需要任何手工清理代码。
下面这张图回答一个问题:一个插件从挂载到卸载,注册与回收各发生在哪一步:
五种派发:事件系统的全部词汇
事件系统是 Cordis 的第三个思想。dsh 的全部扩展点都是事件,而事件的语义只有五种:
24/**25 * Event dispatch strategy used by the event service.26 *27 * `emit` runs synchronous listeners without awaiting them, `parallel` awaits28 * all listeners together, `serial` awaits them in order until one bails,29 * `bail` stops on the first synchronous bail value, and `waterfall` composes30 * listeners around a final `next` callback.31 */32export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'emit:同步触发,不等返回的 Promise,听众旁观。parallel:所有听众并发跑,Promise.allSettled等待全部结束,有异常抛AggregateError。serial:按注册顺序逐个 await,某个返回值不是 null/false/undefined(称为 bail)就停。bail:serial 的同步版。waterfall:洋葱模型,听众包在最终行为外面,每个听众手里有一个next。
五种里最需要读懂的是 waterfall,dsh 的拦截点(agent/pre-step、tools/pre-execute 等)全是它:
234 waterfall(...args: any[]) {235 const cbs = this.dispatch('waterfall', args)236 const inner = args.pop()237 const next = () => {238 const cb = cbs.shift() ?? inner239 return cb(...args)240 }241 args.push(next)242 return next()243 }args 最后一个参数被取出来当 inner,它就是「最终行为」。next() 每次从听众队列头部拿一个执行,队列空了才轮到 inner。一个听众不调 next(),链就在它这里断掉,后面的听众和最终行为都不会执行。调了 next() 并返回它的值,这个值会沿着链一路传回最外层。下面的图回答一个问题:短路发生在哪个位置:
这正是 AGENTS.md 第 107 行那条硬性条款的由来:
107- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).监听器注册:归属 fiber 的一根线
事件监听怎么注册,以及怎么保证卸载时被拔掉:
288 on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {289 if (typeof options !== 'object') {290 options = { prepend: options }291 }292293 // handle special events294 this.ctx.fiber.assertActive()295 listener = this.ctx.reflect.bind(listener)296 const result = this.bail(this.ctx, 'internal/listener', name, listener, options)297 if (result) return result298299 const hooks = this._hooks[name] ||= []300 const label = `ctx.on(${typeof name === 'string' ? JSON.stringify(name) : name.toString()})`301 return this.register(label, hooks, listener, options)302 }第 294 行 assertActive() 保证 fiber 还在活跃期;第 296 行先派发一个 internal/listener 的 bail 事件,允许其他插件拦截这次注册本身(比如改写监听器);第 299 行懒创建事件名对应的 hooks 数组;第 301 行交给 register。register 的实现把「监听器挂在 fiber 上」落实:
254 register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {255 const method = options.prepend ? 'unshift' : 'push'256 return this.ctx.fiber.effect(() => {257 hooks[method]({ ctx: this.ctx, callback, ...options })258 return () => this.unregister(hooks, callback)259 }, label)260 }ctx.on() 的返回值是 register 返回的 disposer。第 256 行 fiber.effect(...):注册监听器这件事本身是一个 effect,effect 体是「把回调塞进 hooks 数组」,effect 体返回的清理函数是「从 hooks 数组里摘掉这个回调」。于是卸载插件时,fiber 逆序跑完所有 effect 的清理,这个监听器被精确摘除,事件数组里不留任何孤儿。
内建事件:框架自己的拦截面
最后一个思想藏在这份 Events 接口里:框架自身的关键操作也全是事件,可以被插件拦截:
329export interface Events {330 /** A plugin fiber was created or its uid was cleared on disposal. */331 'internal/plugin'(fiber: Fiber): void332 /** A fiber changed lifecycle state; receives the fiber and its previous state. */333 'internal/status'(fiber: Fiber, oldValue: FiberState): void334 /**335 * Resolve raw plugin config after the fiber's injections become active.336 * @param config - the raw config for this activation.337 * @mode waterfall338 */339 'internal/config'(this: Fiber, config: any, next: () => any): any340 /** Interception hook for a service binding (no core producer). */341 'internal/service'(this: Context, name: string, value: any): void342 /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */343 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise<void>): void | Promise<void>344 /** Waterfall: a service is being read through the context proxy. */345 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any346 /** Waterfall: a service is being written through the context proxy. */347 'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean348 /** Bail: a listener is being registered; a non-null result replaces registration. */349 'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void350 /** An event is being dispatched to listeners (fired for non-internal events only). */351 'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void352}internal/get 和 internal/set 是 waterfall:每次通过 ctx 读、写一个服务,都经过一条可拦截的链,所以插件可以在不换服务实现的前提下观察甚至改写服务的读写。internal/listener 是 bail:注册监听器这个动作本身可以被另一个插件拦截、替换。internal/dispatch 在每次公开事件派发前触发,是事件总线自己的诊断面。
把「服务读写」和「监听注册」做成事件,意味着框架没有特权:核心机制吃的东西,插件也能吃。这个性质对后面所有章节都是地基,dsh 的 session、tools、agent-loop 全部建立在这些内部事件之上。
依赖声明:加载顺序的答案
最后一个思想是 inject。看 Cordis 官方 primer 的说法:
11- **Declare service dependency via `inject`.** A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing.插件不 import 具体实现,只声明「我要一个叫 llm 的服务」。Registry 在它依赖的服务全部出现后才启动它。服务找服务靠名字,不靠模块引用,这就是「每一个部分都能被替换」在类型层面的落地:替换一个 provider 只需要让新插件用同一个名字 provide。
设计决策分析
为什么 vendor:框架层要完全自持
vendor/README.md 开头两段把这个决定讲得直白:
3This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned).45All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.三个理由:auditable(能审计)、patchable(能改)、pinned(钉死版本)。框架是地基,地基随 npm 升级漂移,产品就没法对行为做承诺。改名到 @deepseek-ai scope 是发布层面的必要:所有 harness 包声明 cordis 为 peer dependency,一旦发布就会把依赖带上 registry,用上游名字发等于抢占上游的命名空间。
vendor 也有纪律约束。vendor/CLAUDE.md 规定不得随意改 vendor/*/src/,每一处对上游的分叉都必须记入 vendor/README.md 的 Local modifications 节。这给「patchable」加了一个对账机制:改了什么、为什么改,都有清单。
扩展点形态:事件系统还是中间件栈
框架选定之后,还有个问题:扩展点用什么形态。dsh 的答案记录在微内核事件分类的 Agent Note 里:
15- **waterfall** (around-middleware) where plugins transform, short-circuit, recover, or wrap: `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.16- **serial** (awaited in listener order) for ordered checkpoints such as `agent/turn-stopping`.17- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint.18- **emit** (synchronous fire-and-forget) for notifications: inbox transitions, lifecycle, errors, and the contained immutable `tools/result` observation. Durable session events own turn and step boundaries.四类扩展点各有语义:要「包装、短路、改写」的用 waterfall,要「有序检查点」的用 serial,要「人人有份」的用 parallel,要「广播通知」的用 emit。Note 里的备选方案一节说明了为什么不自己写一个中间件栈:
24**A purpose-built middleware stack (koa-compose style)** and **an explicit phase state machine plugins insert into** — both would re-implement dispatch, disposal, and reload semantics that Cordis's native event system already provides; as Cordis effects, listeners get HMR and disposal for free.自建中间件栈要重新实现三样东西:派发、回收、热重载。用事件系统,这三样由 fiber 的 effect 机制免费提供。代价在 Note 的 Consequences 里也写明了:waterfall 语义(必须调 next())不直观、必须教、必须用组合测试兜住。
注册即效果:为什么卸载必须对称
AGENTS.md 第 103 行把「注册即效果」列为仓库级的硬性约定:
103- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.这个约定针对的是单体时代的一种经典腐化:功能注册点散落在各处,卸载时有人记得清理、有人不记得,热重载三次之后,旧监听器和新监听器同时活着,同一个事件被处理两遍。把注册统一成 effect 之后,腐化失去了土壤:register() 必须返回 disposer,disposer 必须挂在 fiber 上,fiber 卸载自动回收。对称性是结构保证的,不靠开发者自觉。
不这样做会出什么事,dsh 自己的 postmortem 目录里就有案例。docs/postmortem/0001-acp-default-export-drops-inject.md 记录了插件导出形式混用导致 Loader 丢弃命名空间的真实事故,packages/AGENTS.md 第 5 至 6 行把教训固化成了两条规则。框架提供的可逆性越强,违反可逆性带来的事故就越隐蔽。
边界条件剖析
如果插件在 fiber 卸载过程中注册监听器,会怎样?
会抛 INACTIVE_EFFECT。落点是 vendor/cordis/src/fiber.ts 第 419 至 422 行:effect() 先 assertActive(),再检查 this.state === FiberState.UNLOADING,命中就抛 CordisError('INACTIVE_EFFECT')。这意味着卸载竞态下,注册方会拿到一个明确异常,监听器不会悄悄挂在一个即将死掉的 fiber 上。注意 ctx.on() 走到 register 才进 fiber.effect(events.ts 第 256 行),但 on() 自己先在第 294 行 assertActive() 了一次,两道闸都在同一个状态机上。
如果 waterfall 监听器忘了调 next(),会怎样?
链在它这里静默短路,后面所有监听器和最终行为都不执行。落点是 vendor/cordis/src/events.ts 第 234 至 243 行:next() 逐个 shift 听众,不调 next() 就没有下一次 shift,inner 永远轮不到。对 tools/pre-execute 这种拦截点,这就是「否决」的语义来源;但对「只想记录日志」的监听器,忘调 next() 会让工具调用凭空消失,而且没有任何报错。所以 AGENTS.md 第 107 行才把「必须调 next()」写成条款,microkernel-event-taxonomy.md 第 30 行把「语义必须教、必须用组合测试覆盖」写进后果清单。
如果同一个 disposer 被调用两次,会怎样?
第二次是 no-op。落点是 vendor/cordis/src/fiber.ts 第 427 至 428 行:disposing 标志位在第一次进入时置 true,第二次直接返回第一次的 disposalTask。这个保证让「卸载路径」和「手动清理路径」可以并存:插件自己调了一次 disposer,fiber 卸载时再扫一遍也不会把别的效果清掉两遍。
横向对比
对比对象是 Claude Code 的 hooks 机制。同一个人物,任务都是「在工具执行前拦截」,两边给出的答案形态完全不同。下面的图回答这个问题:两条扩展路径各经过什么:
先看 claude-code 侧的官方示例。仓库 examples/hooks/bash_command_validator_example.py 的文档字符串里内嵌了 hooks 配置:
13{14 "hooks": {15 "PreToolUse": [16 {17 "matcher": "Bash",18 "hooks": [19 {20 "type": "command",21 "command": "python3 /path/to/claude-code/examples/hooks/bash_command_validator_example.py"22 }23 ]24 }25 ]26 }27}拦截逻辑不在 harness 进程里,而在一个外部 Python 进程里,靠 stdin/stdout/exit code 通信:
56def main():57 try:58 input_data = json.load(sys.stdin)59 except json.JSONDecodeError as e:60 print(f"Error: Invalid JSON input: {e}", file=sys.stderr)61 # Exit code 1 shows stderr to the user but not to Claude62 sys.exit(1)6364 tool_name = input_data.get("tool_name", "")65 if tool_name != "Bash":66 sys.exit(0)6768 tool_input = input_data.get("tool_input", {})69 command = tool_input.get("command", "")7071 if not command:72 sys.exit(0)7374 issues = _validate_command(command)75 if issues:76 for message in issues:77 print(f"• {message}", file=sys.stderr)78 # Exit code 2 blocks tool call and shows stderr to Claude79 sys.exit(2)exit code 0 放行、1 只给用户看、2 拦截并展示给模型。整条链路是进程边界的协议:每次工具调用 fork 一个新进程。
dsh 侧的答案是把同一件事翻译成进程内监听:packages/hooks/README.md 第 3 至 4 行写明了这层关系:
5The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception points ([the interception extension-points Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md)); a "native hook" is just an ordinary Cordis plugin on those extension points. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.两组答案各自的代价是清楚的。claude-code 的 hooks 协议把门槛压到最低:任何语言、甚至 shell 脚本都能写拦截逻辑,不用懂 harness 内部结构。代价是每次拦截都跨进程:JSON 序列化、进程启动开销、脚本里没有驻留状态,拦截点的集合由产品方固定发布。dsh 的事件监听把拦截放进进程:可以持有状态、可以精确卸载、可以热重载。代价是插件作者必须用 TypeScript、必须懂 Cordis 的注册与 waterfall 语义。
需要说明的是,2026 年 8 月的 claude-code 公开仓库已不包含主产品源码(packages/ 等目录不存在,检索关键词 cordis、packages/ 均无命中),所以对比侧只能引用其公开的 plugins 目录与 hooks 示例。以上对 claude-code 拦截机制的描述依据其官方示例与 dsh 桥接包对协议的理解,两侧实现细节的对照以各自公开材料为界。
两边为什么各自成立:claude-code 的产品边界固定(单一模型供应商、终端形态),扩展需求集中在「关键时刻给用户一个钩子」,进程协议足够。dsh 的目标是把模型适配器、沙箱、会话日志全部做成可替换,扩展点必须能进进程、能拿 ctx、能逆注册,事件系统是刚需。dsh 没有丢掉 hooks 协议,它把整个协议做成桥接插件,外部 shell hooks 照常能跑,这反过来证明「桥」和「面」是可以分层的。
互动演示设计
演示形态:模拟器。一句话结论:插件卸载时,它注册的一切跟着消失。
舞台比喻:屏幕中央是一个「服务货架」(ctx),上方是事件总线(一根横向导轨,上面有五个接线端子,标着五种派发模式)。插件是「箱子」,挂载 = 开箱、把服务摆上货架、把监听线接到导轨端子;卸载 = 收箱、下架、断线。
分步与字幕:
- 挂载 dsh-shell 插件箱。箱子打开,
Bash服务摆上货架,两条监听线接到tools/pre-execute和tools/execute端子。字幕:「插件挂载:服务上架,监听接线,全部立即生效」。 - 触发
tools/pre-execute。电流沿导轨流向监听线,监听器点亮,画面右侧浮出对应源码行(events.ts第 234 至 243 行的next()链)。字幕:「waterfall 沿注册顺序流过每个监听器,任何一个不调 next 就短路」。 - 点击卸载。箱子收起,服务从货架消失,监听线断开,断开顺序与接线顺序相反。字幕:「卸载按注册逆序回收,effect 收集的 disposer 逐个执行」。
- 再次触发同一事件。电流直接流到内建行为端子,没有监听器被点亮。字幕:「事件照常派发,只是不再经过已卸载的插件」。
读者可操作:拖第二个插件箱上架,给同一事件接线,切换「prepend」开关,观察接线顺序变化(events.ts 第 255 行 unshift vs push)。
逻辑轨迹面板(伪代码行 → 白话 → 真实行号):
plugin.apply(ctx) → 插件启动,拿到 ctx → registry.ts:316
ctx.on('tools/pre-execute', fn) → 声明监听,返回 disposer → events.ts:288
fiber.effect(注册体, 清理体) → 注册体立即跑,清理体挂起 → fiber.ts:418
hooks.unshift(监听器) → 监听器进入事件数组 → events.ts:255
fiber.dispose() → 卸载,逆序执行清理体 → fiber.ts:431
hooks.splice(监听器) → 监听器从数组摘除 → events.ts:272可迁移结论
值得抄的第一条是 disposer 对称:任何注册动作都返回一个 disposer,任何生命周期都由一个持有者统一回收。这条不依赖 TypeScript,也不依赖事件系统,任何语言 30 行内能落地:一个订阅表,subscribe 返回取消函数,持有者销毁时遍历取消函数逆序调用。Go 的 context.CancelFunc、Python 的 contextlib 清理栈都是同一形状。dsh 把它推到极致的地方是「注册与卸载对称」升级成了硬性约定和 lint 规则,这是组织层面的纪律,单点抄代码抄不来。
值得抄的第二条是派发模式显式化。事件系统只有五种模式,每种模式的语义写在类型注释里,扩展点作者不用猜「这个事件的监听器能返回值吗、会并发吗」。你自己的系统哪怕只有「广播」和「可短路链」两种,把它们命名并写死,也好过每个事件自由发挥。
过度设计的部分要认清:Proxy 化的 ctx(每个属性读都过 resolver)、isolate/intercept 的配置拦截、fiber 的 UNLOADING 状态机。这些是为「多租户作用域、插件树可插拔、热重载」服务的,如果你只有一个进程、一组插件、启动时挂载后永不卸载,普通构造函数注入足够,抄全套框架是负担。判断标准是 dsh 自己那条红线:你的产品里有没有「换掉一个供应商、整个产品跟着变」的需求,没有,五分之一套 Cordis 就够用。
思考题
- 动手题:用任意语言写一个不超过 30 行的事件总线,实现两件事:
on(event, listener)返回 disposer;waterfall 派发(最后一个参数是next)。写两个测试:监听器 A 调next(),验证 B 与最终行为都执行;监听器 A 不调next(),验证 B 与最终行为都没执行。跑通后回看vendor/cordis/src/events.ts第 234 至 243 行,找你的实现与它的差异。 - 读
vendor/cordis/src/registry.ts第 316 行起的plugin()与第 300 行的inject()。一个函数插件({ inject, apply })与一个 Service 子类插件,启动路径有什么不同?两个形态的卸载是否同样对称?把依据落到行号。 - 两个插件都对同一个 waterfall 事件注册了
prepend: true的监听器,先注册的插件先卸载再重新挂载,监听器顺序会变成什么样?从vendor/cordis/src/events.ts第 254 至 260 行推导答案,并设计一个打印顺序的小实验验证。 - 读
vendor/README.md的「Local modifications」节,列出 dsh 对上游 Cordis 的全部本地修改。挑其中一处,判断它是产品需求驱动的,还是工程环境(monorepo、ESM、vitest)驱动的,给出判断依据。