Cordis, the kernel underneath

Cordis is a TypeScript plugin framework from the Koishi community. DSH does not implement its own plugin system. It uses this one, which is why a DSH plugin is a Cordis plugin.

A context is a repository of services

Every plugin receives a context, conventionally named ctx. Services live on it under stable keys such as ctx.tools, ctx.llm or ctx.sessions. A plugin that needs the tool table reaches for the key, never for a concrete implementation, so the implementation behind that key can change without the plugin noticing.

context
import type { Context } from 'cordis'

// Services live on the context under stable keys.
// A plugin reaches for the key, not for a concrete class.
export function apply(ctx: Context) {
  ctx.tools     // the tool table
  ctx.sessions  // the append-only session log
  ctx.llm       // whichever model adapter is loaded
}

A plugin is anything that implements Service

In practice that means a function with an apply(ctx) body, or a class extending Service whose lifecycle Cordis mounts into the current context. There is no registration manifest and no plugin base class to inherit from beyond that.

Load order is declared, not sequenced

A plugin lists what it needs in inject. Cordis holds it in a pending state until every listed service exists, then calls apply. Nobody maintains a boot order by hand, and a plugin that arrives before its dependency simply waits instead of crashing.

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

export function apply(ctx: Context) {
  // Cordis holds this plugin in a pending state until both
  // services exist, so neither lookup below can be undefined.
  const session = ctx.sessions.current()
  ctx.tools.list().forEach((tool) => session.note(tool.name))
}

Inside apply, everything named in inject is guaranteed to be there.

Side effects are reversible

Registrations made through ctx.on and ctx.effect are tracked. When a plugin is unloaded or reloaded, Cordis walks that record and undoes each one. Listeners are removed, services are released, timers are cleared. This is what makes hot-swapping a plugin safe rather than a slow memory leak.

reversible effect
export function apply(ctx: Context) {
  const dispose = ctx.tools.register('read_file', async ({ path }) => {
    return readFile(path, 'utf8')
  })

  // Returning the disposer is what makes the plugin removable:
  // unloading it takes the tool back out of the table.
  return dispose
}

Four ways to dispatch an event

Plugins talk to each other through events as well as services, and the dispatch mode decides what a listener can do.

emit

Fire and forget. Every listener runs, nothing is returned.

parallel

Every listener runs concurrently, the caller waits for all of them.

serial

Listeners run in order until one returns a value, which becomes the result.

waterfall

Each listener receives next and can transform the arguments, delegate onward, or short-circuit.

How this shows up in DSH

Every DSH capability is a service on the context. Reading the list is the fastest way to understand what a harness actually consists of.

  • The model adapter claims a service key. Swapping providers means loading a different plugin against the same key.
  • Each tool registers itself into the tool table as a reversible effect, so removing a tool is unloading its plugin.
  • The session service owns the append-only log. Storage backends are separate plugins behind it.
  • The agent loop is itself a plugin, which is why presets like Minimal and PTC can differ so much while sharing a kernel.

The composability model behind Cordis is written up in a paper on spatiotemporal composability, linked from the DSH repository.