Cordis's API is straightforward:
const root = new Context()
await root.plugin(ServiceA)
await root.plugin(pluginB)
Plugins can declare dependencies via inject:
const pluginB = Object.assign((ctx: Context) => {
// use ctx.serviceA
}, {
inject: ['serviceA'],
})
Internal plugin registration uses interfaces like ctx.effect(), ctx.on(), and ctx.provide().
What truly determines this system's capability lies beneath the API: how Context, Fiber, Service Registry, and Effect cleanup collaborate to maintain a constantly changing plugin graph.
This entry dives straight into the source code to analyze this chain.
Context Doesn't Store an Ordinary Map
Cordis's Context is a Proxy-backed dependency container. [2]
When the root Context is created, several core services are installed:
Context
├── fiber
├── reflect
├── registry
├── events
└── logger
Regular property access eventually reaches the Service Resolver, allowing plugin code to directly write:
ctx.tools
ctx.sessions
ctx.llm
Instead of:
container.get('tools')
This is merely an API surface difference. More importantly, Context also carries Scope information.
extend() creates child Contexts; isolate() lets a Service name enter an independent scope within a subtree; intercept() attaches Service-specific config to downstream plugins. [2]
So Context is closer to:
service namespace
+
scope
+
lifecycle owner
+
event environment
Each Plugin Fiber derives its own Context from the parent:
this.ctx = parent.extend({ fiber: this })
This way, Effects generated by the plugin automatically belong to the current Fiber. [1]
Fiber Is the Runtime Instance of a Plugin
ctx.plugin() ultimately creates a Fiber.
The current source code defines the following states for Fiber:
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
Fiber holds:
plugin runtime
config
inject declarations
resolved dependency implementations
owned effects
current epoch
in-flight lifecycle transition
So it bears more responsibility than a simple PluginHandle: maintaining the plugin's validity within the current dependency environment. [1]
When a plugin declares:
inject: ['tools', 'llm']
Fiber will resolve the current implementations of these two Services separately and save them to _store.
If any dependency is missing:
tools = available
llm = missing
Fiber's epoch becomes:
__INACTIVE__
The plugin remains PENDING.
Only when all dependencies are available does the epoch consist of the current Service Provider Fiber's uid:
:12:27
This detail is crucial.
Dependency checking doesn't just verify whether a Service name exists—it also encodes "which implementation currently provides this Service" into the epoch.
Therefore, the following two states are considered different:
tools provided by Fiber 12
and:
tools provided by Fiber 31
Even if the Service name remains tools, a Provider change triggers plugin reloading. [1]
Service Registration Itself Is an Effect
ctx.provide(name, value) doesn't simply write to a Map.
Internally it registers an Effect owned by the current Fiber via:
this.ctx.fiber.effect(...)
[2]
During loading:
store[key] = implementation
If the Provider Fiber is already ACTIVE, registration immediately notifies dependents; if the Service is registered during the plugin's LOADING phase, Cordis waits for the Fiber to become ACTIVE, then the state transition uniformly publishes that Fiber's Service. This prevents dependents from activating prematurely before the Provider itself has finished initializing. [1][2]
During unloading, the disposer:
delete store[key]
notify(name)
This forms a critical lifecycle relationship:
Fiber owns Service
↓
Fiber unload
↓
Service removed automatically
Plugins no longer need to separately maintain:
onUnload(() => unregisterService())
The Service's lifetime is naturally bound to the Fiber providing it.
Event Listeners, Accessors, Mixins, and other capabilities also use similar effect ownership.
This is the most direct implementation of Temporal Composability from the paper in Cordis.
Why B Automatically Becomes Invalid After A Is Unloaded
Now analyze a dependency chain:
Plugin A
└── provides serviceA
Plugin B
├── injects serviceA
└── provides serviceB
Plugin C
└── injects serviceB
Initial state:
A ACTIVE
↓ serviceA
B ACTIVE
↓ serviceB
C ACTIVE
When A is unloaded, A's Service Effect begins disposing.
The disposer corresponding to ctx.provide('serviceA') first deletes the implementation:
delete this.store[key]
Then calls:
this.notify(['serviceA'])
notify() checks the current Registry's Fibers to find consumers that declared the corresponding inject. For B: [2]
serviceA changed
↓
B._checkImpl('serviceA')
↓
implementation missing
↓
delete B._store.serviceA
↓
B._refresh()
_refresh() recomputes B's epoch.
Since the dependency is missing:
Next, _setEpoch() discovers:
old epoch = active dependency epoch
new epoch = INACTIVE
So B enters:
UNLOADING
And executes _unload(). [1]
This answers a commonly misunderstood question: when a Provider is unloaded, it doesn't need to actively find and manually write cleanup for every downstream Plugin.
The Provider only revokes the Service it provides; the Service Registry is responsible for notifying Fibers that depend on that Service, and each Fiber decides whether to deactivate based on its own dependency declarations.
Why B's Unloading Continues to Affect C
B's _unload() cleans up the Effects owned by B.
These include:
provide(serviceB)
So during B's unloading, serviceB is also removed, again triggering:
notify(['serviceB'])
Thus C's dependency state changes:
serviceB disappears
↓
C._refresh()
↓
C epoch = INACTIVE
↓
C unload
The complete chain becomes:
A unload
↓
serviceA removed
↓
B invalidated
↓
B unload
↓
serviceB removed
↓
C invalidated
↓
C unload
There's also an easy-to-miss waiting relationship here. After the disposer from ctx.provide() deletes a Service and calls notify(), it awaits the affected dependent Fibers to reach a stable state. So A's serviceA disposer waits for B to complete this round of lifecycle coordination; when B cleans up serviceB, it waits for C to stabilize. [2]
The async relationships can be visualized as:
dispose serviceA
│
├── remove serviceA
├── notify B
│ │
│ └── B unload
│ │
│ ├── dispose serviceB
│ │ ├── remove serviceB
│ │ ├── notify C
│ │ └── await C settled
│ │
│ └── B settled
│
└── await B settled
This explains why resources on the dependency chain aren't simply "sliced off" in one go. Services first become unavailable for new resolution, then downstream Fibers complete their own cleanup; upstream disposers wait for directly affected Fibers to stabilize. Dependency relationships continue propagating downward through each layer's provided Services.
This is indeed a cascade, but not an explicitly written recursive algorithm.
Each layer only handles two local facts:
Service changed
Fiber dependencies changed
New Service changes continue triggering the next layer, until the system reaches a stable state with no more dependency changes.
Therefore, a more accurate description is: Cordis drives local reconcile through Service change events, with the dependency chain naturally propagating through multiple local state transitions.
The current vendored implementation's notify() iterates through Fibers in the Registry, then filters affected objects by inject and isolation scope. Semantically it's a local update by dependency name, but the implementation doesn't require pre-maintaining a complete reverse dependency graph. [2]
Does Unloading Happen Immediately
After a Service is removed, dependent Fibers immediately begin lifecycle transitions, but the unloading process itself can be asynchronous.
Fiber saves:
inertia: Promise<void> | undefined
Represents the currently executing load/unload transition. [1]
When the epoch changes, _setEpoch() updates the target epoch. If inertia already exists, it won't concurrently start a second lifecycle task.
For example, while B is unloading, serviceA reappears:
B UNLOADING
serviceA appears
At this point, B's target epoch will become the active epoch again, but _setEpoch() sees that inertia already exists and won't directly execute _reload() concurrently.
After the current _unload() completes, it checks the target epoch again:
if target epoch is inactive
stay pending
else
reload with latest epoch
So the actual process is:
dependency lost
↓
target epoch = INACTIVE
↓
start unload
↓
dependency returns during unload
↓
target epoch updated
↓
finish current unload
↓
reload with latest epoch
This avoids a single Plugin executing load and unload simultaneously.
The epoch here can be understood as Fiber's version identifier for "the current dependency world."
Only when the target dependency environment changes does lifecycle need to be re-coordinated.
Effect Disposal Order
Fiber maintains its own _disposables.
DisposableList.clear() returns disposers in reverse registration order, so later-registered Effects enter teardown first. [1]
However, Fiber's _unload() uses Promise.all for async cleanup on these top-level disposers. In other words, top-level Effect invocation order is initiated in reverse, but there's no global serial guarantee on async completion order.
Disposers collected inside a single ctx.effect() execute in reverse order one by one.
This is also what DeepSeek Harness's Cordis Primer specifically notes: if strict teardown order exists between certain resources, they should be placed in the same Effect, with that Effect defining its own disposal order. [7]
This constraint is practical. The framework handles lifecycle ownership and shouldn't guess business ordering between two independent resources by default.
When Does Context Recalculation Happen
Cordis doesn't have a background loop continuously scanning all plugins.
Core triggers come from structural changes:
Service registered
Service removed
Service implementation changes
plugin config changes
plugin restart / dispose
Using Service as an example:
ctx.provide()
↓
reflect.notify()
↓
affected fibers _checkImpl()
↓
_refresh()
↓
compute dependency epoch
↓
_setEpoch()
If the epoch hasn't changed:
if (epoch === oldEpoch) return
No lifecycle action occurs.
If changing from INACTIVE to active:
PENDING → LOADING → ACTIVE
If changing from active to INACTIVE, or Provider identity changes:
ACTIVE → UNLOADING
When Provider identity changes, after unloading completes, it reloads according to the new epoch.
Therefore, the smallest unit of reconcile is Fiber, and the trigger source is the runtime environment change for services it declares dependencies on.
Why DeepSeek Harness Needs This Layer
DeepSeek Harness's current architecture installs almost all major capabilities as plugins: [6]
core/session → ctx.sessions
core/tools → ctx.tools
core/agent → ctx.agents
core/agent-loop → ctx.agentLoop
llm/llm → ctx.llm
system-prompt → ctx.systemPrompt
The official architecture documentation directly describes: model adapters, Tool Registry, Session Log, and the Agent Loop itself all belong to plugins—none require all extensions to patch a privileged kernel. [6]
This means DeepSeek Harness's runtime structure is closer to:
Cordis Context
│
├── Session Service
├── LLM Service
├── Tool Service
├── Agent Registry
├── Agent Loop
├── Persistence
├── Sandbox
└── ...
The Agent Loop itself can depend on:
Session
LLM
Tools
SystemPrompt
When some Provider is replaced, Plugin Fibers depending on it can re-coordinate following the same epoch mechanism.
So what "Everything is a Plugin" really needs isn't just a unified Loader. The genuinely difficult part is:
plugins appearing and disappearing
+
Services appearing and disappearing
+
dependency changes
+
complete side-effect cleanup
+
asynchronous lifecycles not competing with each other
Cordis provides precisely this runtime semantics.
Understanding from Spring's Perspective
If you're familiar with Spring, you can draw limited correspondences:
Cordis Context
≈ ApplicationContext + scoped runtime environment
Service
≈ runtime-provided Bean capability
inject
≈ dependency declaration
Fiber
≈ BeanDefinition + instance lifecycle owner + scope state
Effect disposer
≈ DisposableBean / destruction callback
The correspondences are only for establishing a starting point—they can't be directly equated.
Spring's typical ApplicationContext structure is relatively stable after refresh. Bean dependencies are mainly resolved during creation; removing a Bean at runtime and having all downstream Beans automatically deactivate, then reactivate after dependencies restore, isn't part of the conventional BeanFactory lifecycle model.
Cordis makes this dynamic structural change a normal operating path.
So its focus isn't "one more DI container than Spring"—it's weaving DI together with lifecycle reconcile.
Costs of This Model
Dynamic composition isn't free.
First, plugin code needs to correctly declare dependencies. Implicitly reading Services breaks the Runtime's dependency awareness.
Second, all external registration should enter Effect ownership. Bypassing Context to directly write to global structures leaves resources that can't be automatically cleaned up.
Third, plugins must be able to repeatedly load/unload. Initialization functions no longer default to executing only once.
Finally, dynamic dependencies increase state machine complexity. PENDING/LOADING/ACTIVE/UNLOADING/FAILED, async cleanup, re-entry, and HMR all require rigorous handling.
Therefore, Cordis is suitable for systems that genuinely need runtime composition. For small Agents with fixed capabilities, abstracting everything as dynamic plugins only increases understanding cost.
DeepSeek Harness's product goals include plugin replacement, configuration composition, HMR, isolated Scopes, and extensive extension points—making this complexity clearly justified.
The next Demo will compress Cordis's core semantics into a minimal implementation: Service, Dependency, Effect, Fiber, and reconcile. Through actual execution, you can observe how the A → B → C dependency chain deactivates, then progressively reactivates layer by layer as A restores.
References
[1] Cordis Fiber source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts
[2] Cordis Reflect / Service resolution: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts
[3] Cordis Context source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/context.ts
[4] Cordis Registry source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/registry.ts
[5] Cordis Service source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/service.ts
[6] DeepSeek Harness Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md
[7] DeepSeek Harness Cordis Primer: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.md