Every plugin loaded into Harness runs in its own Fiber scope, going through a full lifecycle from declaration, loading, and running to unloading. This tutorial covers the six states of the Fiber state machine, dependency-driven loading and automatic unloading, automatic cleanup of resources registered through ctx, the execution semantics of ctx.effect, nested contexts, and manual dispose and hot replacement. Understanding the lifecycle is the prerequisite for plugins that do not leak resources and reload reliably.
Every plugin gets its own Fiber
A plugin is not a static module that is simply loaded and forgotten. The framework creates a Fiber for every loaded plugin — an isolated scope and lifecycle unit. Everything the plugin does through ctx inside apply (registering listeners, registering tools, acquiring resources) is booked under this Fiber, and the framework reclaims it all when the plugin unloads.
Start with a minimal plugin and watch its lifecycle output:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'lifecycle-demo'
export function apply(ctx: Context) {
console.log('[lifecycle-demo] plugin loading')
// setup runs immediately; the returned cleanup runs on unload
ctx.effect(() => {
console.log('[lifecycle-demo] effect registered')
return () => {
console.log('[lifecycle-demo] effect cleaned up')
}
})
}
On load, apply prints "plugin loading", and the ctx.effect setup runs immediately and prints "effect registered"; on unload, the cleanup function runs and prints "effect cleaned up". The steps below break each of these behaviors down.
The state machine: six states
At any moment, each Fiber is in one of six states:
- PENDING — declared, but required dependencies are not ready yet;
- LOADING — dependencies are ready and
applyis executing; - ACTIVE — the plugin is running;
- FAILED —
applythrew an error; - UNLOADING — the plugin is unloading and releasing resources;
- DISPOSED — the plugin is fully unloaded.
The happy path goes through PENDING, LOADING, ACTIVE, and UNLOADING, ending at DISPOSED. If apply throws while executing, the plugin goes from ACTIVE to FAILED — an error branch with no automatic retry; fix the code and reload.
Dependency-driven loading and unloading
A plugin that declares inject does not load immediately — it stays PENDING until every required service is ready, and only then enters LOADING. This is why injected services are always usable inside apply: the framework eliminates the timing race for you.
The reverse also holds: if a required service disappears at runtime (for example, when its provider is replaced), every plugin that depends on it is unloaded automatically, going ACTIVE straight to DISPOSED; when the service comes back, those plugins reload automatically. No reconnection or retry logic on your side.
Automatic cleanup: everything via ctx
The Fiber’s most convenient property: every registration made through ctx is revoked automatically on unload. The framework tracks and disposes of four kinds of registrations:
ctx.on(event, handler)— event listeners;ctx.tools.register(tool)— tool registrations;ctx.llm.registerAdapter(names, adapter)— LLM adapter registrations;ctx.effect(() => cleanup)— custom resources.
In the snippet below, event, handler, tool and so on are placeholders for the objects you would register in a real plugin:
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
// all of these registrations are revoked automatically on unload
ctx.on(event, handler) // event listener
ctx.tools.register(tool) // tool registration
ctx.llm.registerAdapter(names, adapter) // LLM adapter
// custom resource: setup runs immediately, the returned cleanup runs on unload
ctx.effect(() => {
const timer = setInterval(() => {}, 1000)
return () => clearInterval(timer)
})
}
This means most plugins need no teardown code at all: listeners and tools vanish with the Fiber. Only resources created outside ctx (raw timers, native connections, file handles) need to be registered explicitly with ctx.effect.
ctx.effect semantics
Two semantics of ctx.effect are easy to get wrong, and they decide whether your cleanup is reliable.
Setup runs immediately
The setup function you pass in runs synchronously the moment you call ctx.effect; only the returned cleanup function is deferred until unload. So acquiring the resource and registering its cleanup happen at the same instant — do not treat ctx.effect as an unload-only hook, its first line runs right now.
Cleanup order: reverse start, concurrent execution
On unload, disposers start in reverse registration order — but note: multiple async disposers run concurrently, and the framework does not guarantee they finish serially. If your cleanup steps depend on order (the session must close before the connection), put them in a single disposer and await them serially inside it:
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
const conn = createConnection()
const session = conn.startSession()
// order-dependent cleanup: one disposer, awaited serially inside
ctx.effect(() => {
return async () => {
await session.close() // close the session first
await conn.close() // then drop the connection
}
})
}
Nested contexts and child Fibers
ctx.plugin() creates a child Fiber under the current one. The child inherits the parent context (it can reach the same services) but has an independent lifecycle:
import type { Context } from '@deepseek-ai/cordis'
// child plugin: inherits the parent context, with its own lifecycle
const childPlugin = {
name: 'child-plugin',
apply(ctx: Context) {
console.log('[child] loaded')
},
}
export function apply(ctx: Context) {
const fiber = ctx.plugin(childPlugin)
console.log('[parent] child fiber created:', fiber)
}
A typical use is splitting a large plugin into internal modules that each manage their own registrations. When the parent unloads, its children unload with it — no manual cleanup required.
Manual dispose and its guarantees
ctx.plugin() returns a handle to the child Fiber, and calling dispose() on it unloads the plugin manually:
import type { Context } from '@deepseek-ai/cordis'
export async function apply(ctx: Context) {
const fiber = ctx.plugin(myPlugin)
// unload manually when needed
await fiber.dispose()
// 1. every registration owned by the plugin is removed
// 2. child plugins are unloaded recursively
// 3. the promise resolves only after all async cleanup finishes
}
await fiber.dispose() gives three guarantees: every registration owned by the plugin is removed; child plugins are unloaded recursively; and the returned promise resolves only after all async cleanup finishes. The third one matters most — dispose() does not return as soon as unload starts, it returns when cleanup is fully done. When your next step depends on the unload being complete (a reload, for example), awaiting it is the safe move.
Hot replacement (HMR)
Restarting on every edit is slow. With @deepseek-ai/cordis-plugin-hmr loaded from cordis.yml, saving a plugin source file triggers hot replacement automatically:
# cordis.yml: enable the HMR plugin
- insert:
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
Hot replacement takes three steps: unload the old plugin and clean up all its registrations, load the new code, then run the new apply. No Harness restart needed — edits take effect on save.
Lifecycle FAQ
Does a plugin retry automatically after entering FAILED?
No. FAILED is the state after apply throws, and the framework does not retry automatically. Fix the code and reload — restart Harness during development, or let HMR hot replacement reload it for you.
When does the ctx.effect setup function run?
Immediately and synchronously — setup runs the moment you call ctx.effect; the returned cleanup function is deferred until the plugin unloads. Do not treat it as an unload-only hook.
In what order do multiple cleanup functions run?
On unload, disposers start in reverse registration order, but multiple async disposers run concurrently with no serial completion guarantee. If cleanup B must finish after cleanup A, put both steps in a single disposer returned from one ctx.effect and await them serially inside it.
Does hot replacement keep registrations from the old plugin?
No. HMR first fully unloads the old plugin and revokes all its registrations, then loads the new code and runs the new apply. Never rely on state left behind by the old instance.
Do child plugins created with ctx.plugin unload with their parent?
Yes. A child Fiber inherits the parent context but has an independent lifecycle; when the parent unloads, its children unload with it. Calling fiber.dispose() manually also recursively unloads all child plugins.