Cordis is not a plugin host with a privileged core. It is a small runtime whose only unit of work is a plugin. This repo vendors that core as @deepseek-ai/cordis from packages/core on that tree. YAML loading is a separate plugin (@cordisjs/plugin-loader). The kernel would still work if you never used a config file.
The implementation is five mechanisms that lock together: a callback, a fiber, a proxied context, a reactive provide/inject graph, and effects.
The registry accepts three syntactic shapes and collapses them to one function:
export type Plugin<T = any> =
| Plugin.Function<T>
| Plugin.Constructor<T>
| Plugin.Object<T>
// ...
export interface Function<T = any> extends Base<T> {
(ctx: Context, config: T): any
}
export interface Constructor<T = any> extends Base<T> {
new (ctx: Context, config: T): any
}
export interface Object<T = any> extends Base<T> {
apply(ctx: Context, config: T): any
}plugin() does not “install a package.” It resolves that callback, records a runtime keyed by the function identity, and constructs a fiber — one live instance with its own config and cleanup list:
plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
const callback = this.resolve(plugin)
if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin)
this.ctx.fiber.assertActive()
// ...
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)At activation time the fiber either news a class or calls the function. That is the entire difference between a Service subclass and export function apply:
execute: function () {
if (isConstructor(runtime.callback)) {
const instance = new runtime.callback(this.ctx, this.config)
// ...
return instance?.[symbols.init]?.()
} else {
return runtime.callback(this.ctx, this.config)
}
},Upstream’s own README shows the same two shapes side by side: a Counter service class, and a greeter function with inject: ['counter'], both started with root.plugin(...).
A fiber is not a thread. It is a state machine plus a stack of disposers:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
- PENDING — declared, but a required service is missing.
- LOADING / ACTIVE — the callback is running / has finished.
- UNLOADING — collected disposers run in reverse.
ctx.plugin(child) from inside a plugin creates a child fiber. Disposing the parent disposes the children. That is how composition nests without a second tree type: a plugin that mounts other plugins is still just fibers.
new Context() installs four built-in services (reflect, registry, events, logger) and returns a Proxy. Ordinary reads such as ctx.counter do not look up an own property. They go through ReflectService.handler.get:
static handler: ProxyHandler<Context> = {
get: (target, prop, ctx: Context) => {
// own properties and symbols first...
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {
const key = target[symbols.isolate][prop]
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) {
throw error // required service, fiber not active
}
// walk parent fibers while isolation label matches
fiber = fiber.parent.fiber
}
})
},That is why plugins never import a concrete Counter. They declare inject: ['counter'] and then read ctx.counter. The name is the coupling. TypeScript declaration merging (interface Context { counter: Counter }) is only the type side of the same name.
ctx.plugin, ctx.on, ctx.effect, ctx.provide look like methods on Context. They are mixins: accessors that forward to registry / events / fiber / reflect, so those APIs dispose with the core services.
this.mixin('reflect', ['get', 'set', 'provide', 'accessor', 'mixin'])
this.mixin('fiber', ['runtime', 'effect'])
this.mixin('registry', ['inject', 'plugin'])
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall'])When a Service subclass runs super(ctx, 'counter'), it immediately provides itself. Provide is an effect: the implementation is stored under an isolation symbol, and dependents are notified.
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
this.ctx.root[symbols.isolate][name] ??= Symbol(name)
const key = this.ctx[symbols.isolate][name]
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
if (this.store[key]) {
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
}
this.store[key] = impl
// ...
return async () => {
delete this.store[key]
const fibers = this.notify([name])
await Promise.allSettled(fibers.map(fiber => fiber.await()))
}
}, `ctx.provide(${JSON.stringify(name)})`)
}notify walks every fiber that listed that name in inject, updates its local impl snapshot, and calls _refresh:
notify(names: string[], ...) {
for (const runtime of this.ctx.registry.values()) {
for (const fiber of runtime.fibers) {
// ...
fiber._checkImpl(name)
fiber._refresh()
}
}
}_refresh concatenates the providing fibers’ uids into an epoch. Missing any injectee sets the epoch inactive (unload). When every name is present, the fiber loads:
_refresh() {
let epoch: string | boolean = false
epoch = ''
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)
}That is the whole “plugin system” scheduling story:
- You can
plugin()dependents before providers. They sit inPENDING. - When the provider becomes
ACTIVE, dependents activate themselves. - If the provider unloads (HMR, config disable, dispose), dependents unload, then reload if a replacement appears.
- Two plugins cannot provide the same name in the same isolation realm; the store key is a
Symbol, and a duplicate throws.
Load order is a graph of service availability, not a sorted list of files.
flowchart LR
A["ctx.plugin(Greeter)<br/>inject: counter"] --> P["fiber PENDING"]
B["ctx.plugin(Counter)<br/>provide('counter')"] --> N["notify('counter')"]
N --> R["_refresh epoch"]
R --> L["Greeter LOADING → apply()"]
L --> E["ctx.on('app/ready') is an effect"]
ctx.effect(fn) runs fn immediately and collects every disposer it returns, yields, or awaits. Those disposers run in reverse on unload. ctx.on is the same primitive: it pushes a listener, and the disposer splices it out.
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback)
}, label)
}So a plugin does not mutate a global singleton and hope teardown remembers. Its footprint is the fiber’s disposer list: provided services, listeners, child plugins, timers you wrapped yourself. Dispose the fiber and the plugin is gone — which is also how HMR works: dispose the old fiber, start a new one with the new module.
That is the “temporal” half of Cordis’s spatiotemporal composability claim: plugins appear and disappear over time, and the runtime’s job is to keep the graph consistent.
Declaration merging types the bus (interface Events { 'app/ready'(message: string): void }). Dispatch modes are different folding strategies over the same hook list:
| Mode | Behavior |
|---|---|
emit |
fire-and-forget, registration order |
parallel |
await all |
serial / bail |
stop on first truthy result |
waterfall |
each listener receives next; skip next() to veto |
Waterfall is Koa-style wrapping, implemented by treating the last argument as the inner callback and shifting listeners:
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)
return next()
}Listeners are also filtered by context. A service can set Context.filter so an event dispatched as that service only reaches listeners in the same isolation scope. That is how one ctx.tools registry can have per-agent listeners without a second event bus.
ctx.isolate('counter') returns a child context whose isolation map has a new Symbol for counter. Provides and lookups under that child use a different store slot, so a second Counter can exist without colliding with the parent’s. Sharing a label joins two subtrees into one realm.
ctx.intercept('loader', { await: true }) stacks config on a prototype chain; Service[resolveConfig] merges ancestor intercepts. That is per-plugin config overlay, not a second DI container.
Together with child fibers, this is the “spatial” half: the same plugin code can run in many places at once, each with its own services and cleanup.
Core Cordis has no cordis.yml. @cordisjs/plugin-loader is itself a Service that owns an entry tree. For each row it import()s name, unwraps default vs named exports, and calls registry.plugin(plugin, config) — the same API you would call from TypeScript.
That is why DeepSeek Harness can say the agent loop is a plugin: boot() is new Context(); ctx.plugin(Loader); mount include. After that, every product row is one more fiber.
The trick, compressed: Cordis implements plugins by making lifetime first-class (fiber + effects), making names the only coupling (provide / inject / Proxy), and making activation a reactive function of those names (notify → _refresh). There is no plugin manager sitting above that graph. The graph is the application.