Skip to main content
All posts
Development

Services, Dependency Injection, and Plugin Lifecycle in DeepSeek-Harness

How the Fiber state machine loads and unloads DeepSeek-Harness plugins, what inject and the Service class actually do, and why ctx.effect exists for cleanup.

Every DeepSeek-Harness (dsh) plugin moves through a lifecycle state machine — PENDING → LOADING → ACTIVE, or → FAILED if loading errors out, then ACTIVE → UNLOADING → DISPOSED on teardown. inject, the Service class, and ctx.effect() are the three tools a plugin author uses to hook into that state machine correctly instead of fighting it.

The Fiber state machine

PENDING → LOADING → ACTIVE
                 ↘ FAILED
ACTIVE → UNLOADING → DISPOSED

A plugin starts PENDING. If it declares inject, dsh holds it there until every injected service exists, then moves it to LOADING and calls apply(ctx, config). A clean apply call lands the plugin in ACTIVE; a thrown error lands it in FAILED instead. From ACTIVE, a plugin moves to UNLOADING — triggered by the process shutting down, a dependency disappearing, or an HMR reload — where everything the plugin registered through ctx gets automatically torn down, before finally reaching DISPOSED.

The practical upshot: you don't write your own dispose logic for the common case. Event listeners, ctx.tools.register() calls, and anything else registered through ctx during apply are cleaned up by the framework itself when the plugin unloads.

Declaring required dependencies with inject

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx) {
  ctx.tools.register(/* ... */)
}

inject is an array of service names. It does two things simultaneously: it delays LOADING until every named service is present on ctx, and it makes the plugin unload automatically if any of those services later disappears — for example, if the plugin providing tools itself gets unloaded. This is what makes ctx.tools safe to use unconditionally inside apply when tools is in your inject list — by the time your code runs, the framework has already guaranteed it exists.

Optional dependencies with ctx.get

Not every dependency should block loading. When a plugin can work with or without another service — enhancing its behavior if present, degrading gracefully if not — ctx.get('serviceName') does a one-off lookup instead of gating the whole plugin on inject:

export function apply(ctx) {
  const metrics = ctx.get('metrics')
  if (metrics) {
    // enhance behavior if the metrics service happens to be loaded
  }
}

Use inject when a service is required for the plugin to make sense at all; use ctx.get when it's a nice-to-have.

Building a service: the Service class

The class-style plugin form exists specifically for plugins that expose a capability other plugins can depend on:

export default class MyService extends Service {
  static inject = ['tools']
  constructor(ctx: Context) {
    super(ctx, 'myService') // mounts as ctx.myService
  }
}

super(ctx, 'myService') is what mounts the instance at ctx.myService for every other plugin in the tree. Once mounted, any other plugin declares inject: ['myService'] and gets the same guarantees described above — it won't load until your service is ready, and it'll unload automatically if your service goes away. This is the mechanism the docs point to for the "capability seam" pattern: a service definition, one or more concrete providers implementing it, and one or more consumers injecting it — the same shape ctx.tools itself follows underneath the framework's own built-in services.

Depending on more than one service

inject isn't limited to a single entry — a plugin that needs both the tool registry and the LLM adapter registry declares both, and waits for both:

export const inject = ['tools', 'llm']

export function apply(ctx) {
  // both ctx.tools and ctx.llm are guaranteed to exist here
}

dsh doesn't call apply until every name in the array is satisfied, and the plugin unloads if any of them later disappears — there's no partial-dependency state where only some injected services are guaranteed present. If a plugin's behavior genuinely still makes sense with some dependencies missing, that's a signal to split the optional ones out into ctx.get() calls instead of adding them to inject, per the previous section.

Cleaning up with ctx.effect()

Most registrations (event listeners, ctx.tools.register()) are cleaned up automatically without you doing anything. ctx.effect() exists for the cases that aren't automatic — a resource your plugin opened by hand, like a timer, a socket, or a file watcher, that needs an explicit teardown function:

export function apply(ctx) {
  ctx.effect(() => {
    const timer = setInterval(() => console.log('heartbeat'), 5000)
    return () => clearInterval(timer) // called automatically when the plugin unloads
  })
}

The function you pass to ctx.effect() runs immediately and returns its own cleanup function; dsh calls that returned function during UNLOADING, so clearInterval runs without you having to listen for an unload event yourself. Reach for ctx.effect() any time you're using a raw Node.js or browser API that isn't already wrapped by something dsh manages for you.

isolate: scoping a service to part of the plugin tree

cordis.yml supports isolate, which gives a named group of plugins their own separate instance of a service instead of sharing the process-wide one — for example, so one group of plugins gets a Bash executor with a different timeout than another group, without either group's config bleeding into the other's. This is a configuration-file-level concern more than something you write inside apply itself; see cordis.patch.yml Explained for how insert/id/config entries compose in the file where isolate is declared.

HMR and the lifecycle you already have

Hot module replacement, via @deepseek-ai/cordis-plugin-hmr, doesn't add a new lifecycle — it drives the existing one from a file-save event instead of a process shutdown. Editing a plugin's source triggers the exact same UNLOADING → DISPOSED teardown described above for the old code, followed by PENDING → LOADING → ACTIVE for the newly loaded module. That's why writing correct cleanup — through the automatic path for ordinary registrations, or ctx.effect() for anything else — pays off during everyday development, not just at process shutdown: every HMR reload exercises it.

FAQ

What happens to a plugin's registrations if it fails during apply?

It lands in FAILED rather than ACTIVE. The docs we reviewed establish the state exists but don't detail partial-cleanup semantics for a plugin that registered some things before throwing — treat a plugin's apply as ideally either fully succeeding or throwing early, before registering anything with side effects that would need manual cleanup.

Do I need ctx.effect() for ctx.tools.register()?

No — tool registration, event listeners, and most ctx.* registrations are torn down automatically when the plugin unloads. ctx.effect() is specifically for resources you created yourself outside of a dsh-managed registration API, like a raw setInterval.

Can a plugin depend on a service that doesn't exist yet, but might load later?

Yes — that's exactly what inject handles. The plugin stays in PENDING until the dependency shows up, then proceeds to LOADING automatically; you don't poll or retry manually.

Is Service required for every plugin?

No. Most plugins are function-style or object-style and never extend Service — you only reach for the class form when your plugin itself needs to expose something other plugins will inject.

Next steps

See where inject and Service fit inside a full working plugin in Build a DeepSeek-Harness Plugin from Scratch, and for the framework these lifecycle rules come from, see Cordis Explained: The Plugin Framework Behind DeepSeek-Harness. For the theory-level version of the same contract, read How DeepSeek-Harness Plugins Work Under the Hood, or browse real service-providing plugins in Development & Runtime on FindHarness.