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:

src/lifecycle-demo.ts
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 apply is executing;
  • ACTIVE — the plugin is running;
  • FAILEDapply threw an error;
  • UNLOADING — the plugin is unloading and releasing resources;
  • DISPOSED — the plugin is fully unloaded.
Fiber state machine: PENDING, LOADING, ACTIVE, UNLOADING, DISPOSED in sequence, with ACTIVE forking to FAILED when apply throwsPENDINGdeps not readyLOADINGapply runningACTIVErunningUNLOADINGreleasingDISPOSEDfully unloadedFAILED when apply throwsFAILEDapply threwOn unload, everything registered through ctx is cleaned up automatically
The Fiber state machine: the happy path proceeds in sequence, forking from ACTIVE to FAILED when apply throws.

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:

All four registration kinds are cleaned up by the framework
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:

Put order-dependent cleanup in one disposer
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:

Create a child Fiber with ctx.plugin
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:

Dispose a Fiber 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:

Enable hot replacement
# 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.