Architecture · 04

Everything Is a Plugin: Cordis, Context, and Reversible Effects

Follow a capability from Context and Fiber through Service, Event, Effect, configuration, mounting, and verified disposal.

Reading time
17 minutes
Sources verified

Understand what the plugin architecture is solving

DeepSeek Harness does not treat plugins as optional decorations around a privileged agent kernel. The model adapter, session store, Agent Loop, tools, persistence, settings, credentials, Web server, and UI integrations all enter the runtime through Cordis composition. That uniformity solves a practical extension problem: a deployment can replace a provider, remove a consumer, isolate a per-agent capability, or unwind a feature without editing a central switch statement.

The benefit comes with a discipline. A package existing in node_modules does not make its capability available. A plugin must be mounted in a Context, its dependencies must resolve, its Fiber must reach an active state, and any Service contract it consumes must have an appropriate provider. Configuration determines which rows mount. Lifecycle determines when registrations appear and disappear. The correct debugging question is therefore not ‘was the file imported?’ but ‘which Fiber owns this behavior, and did its effects commit?’

  • Context defines the scoped view of services and events.
  • Fiber represents a mounted plugin instance and its lifecycle.
  • Service gives a capability a named contract.
  • Event connects producers and consumers with a declared dispatch mode.
  • Effect registers behavior together with its cleanup.
  1. Consumer and integration packages depend on core Harness contracts.
  2. Core packages depend on capability definitions and utility packages.
  3. Arrows describe package peer dependencies, not runtime request flow.
Simplified package dependency layers. The official graph is generated from package peerDependencies. Official source ↗

Use Context as the capability and ownership boundary

A Cordis Context is the object a plugin receives when it is applied. It exposes inherited services, event methods, logging, schema facilities, and lifecycle registration. Child contexts can filter or extend the view without mutating a process-global container. This is why a consumer should obtain a dependency from ctx rather than importing the concrete provider package: the active composition owns provider selection.

Context also establishes ownership. When a plugin registers an event listener or service through its Context, Cordis can associate the registration with that plugin's Fiber. Disposal can then remove the listener or service deterministically. Code that escapes to a global singleton breaks that relationship; hot reload may leave duplicate listeners and tests may leak behavior into later cases.

typescript
export function apply(ctx: Context) {
  const dispose = ctx.on('example/ping', (value) => {
    ctx.logger('example').info('ping %s', value)
  })

  // Cordis owns the listener through this plugin Context.
  // Explicit cleanup can still be returned or registered when needed.
  return dispose
}

Follow the Fiber lifecycle instead of guessing from side effects

A Fiber is the lifecycle carrier for one mounted plugin instance. It tracks parent-child relationships, dependency readiness, activation, and disposal. A child cannot safely consume a required service before the provider is available. Cordis coordinates this readiness rather than asking every plugin to poll global state. When configuration removes or replaces a row, the old Fiber unwinds before the new behavior commits.

This model is spatial and temporal. Spatially, a child inherits the capabilities visible through its parent Context. Temporally, those capabilities exist only while the owning providers are active. A service can be available in one branch and absent in another. A per-agent preset can isolate a service row into its own realm, while host services remain outside that isolated tree.

text
root Context
├─ base Fiber
│  ├─ session service Fiber
│  ├─ tools service Fiber
│  └─ model provider Fiber
└─ web-app Fiber
   ├─ web server Fiber
   └─ client modules Fiber

Dispose web-app → its children unwind; base services remain.

When a feature appears missing, inspect the plugin tree and dependency logs. A pending Fiber often indicates an unavailable required service. A disposed Fiber explains why a listener no longer fires. A duplicated behavior after reload suggests an effect escaped lifecycle ownership.

Define a Service contract and keep consumers provider-neutral

A Service is a named capability on Context. The Definition owns stable types and semantics. One or more Provider packages implement it. Consumers declare that they require the service and call its contract without importing a provider. DeepSeek Harness uses this seam for credentials, filesystem, sandbox, persistence, subprocesses, settings, and other capabilities whose implementation changes across environments.

typescript
declare module 'cordis' {
  interface Context {
    greetings: GreetingService
  }
}

export interface GreetingService {
  greet(name: string): Promise<string>
}

export const inject = ['greetings']

export async function apply(ctx: Context) {
  ctx.logger('consumer').info(await ctx.greetings.greet('Ada'))
}

The example illustrates contract placement, declaration merging, dependency declaration, and provider-neutral use. In a real Harness package, follow the repository's package template and invariant conventions rather than inventing a private registration mechanism. A provider should validate configuration before exposing the service, and a consumer should not include a fallback that silently bypasses the seam.

text
Trace checklist
1. Find ctx.<service> in docs/capability-seams.md.
2. Identify the Definition package and public type.
3. Identify mounted Provider rows in dump-config.
4. Identify Consumers and their declared injection.
5. Confirm the provider is visible in the consumer's Context branch.

Choose an Event dispatch mode as part of the public contract

Typed Events decouple a producer from any number of listeners, but dispatch is not one generic operation. Cordis documents emit, waterfall, parallel, and serial modes. emit notifies observers; parallel awaits a fan-out; serial awaits listeners in order; waterfall forms a wrapping chain in which a listener must call next() to delegate. DeepSeek Harness marks event declarations with their mode because dispatch behavior is part of the event contract.

Selecting the wrong mode creates subtle failures. A policy interceptor implemented as emit cannot wrap downstream execution. A waterfall listener that forgets next() intentionally or accidentally stops the chain. A serial lifecycle hook has ordered side effects but no continuation callback. Read the owning event declaration and generated producer-consumer matrix before adding a listener.

typescript
ctx.on('request/around', async (request, next) => {
  const started = performance.now()
  try {
    return await next(request)
  } finally {
    ctx.logger('timing').info('%dms', performance.now() - started)
  }
})
text
emit:      producer → listener A, listener B (observation)
parallel:  producer → await [A, B] (fan-out)
serial:    producer → await A → await B (ordered)
waterfall: producer → A(next → B(next → target)) (wrapping)

Register every side effect with a reversible cleanup path

An Effect is a registration whose disposer is tied to plugin lifetime. Event listeners are effects, but the same principle applies to timers, filesystem watchers, child processes, temporary routes, and resources created by libraries. Apply should either use Context-owned helpers or register cleanup immediately after creation. Cleanup must tolerate partial startup because configuration validation or a later dependency can fail after some resources exist.

typescript
export function apply(ctx: Context) {
  const timer = setInterval(() => ctx.emit('example/tick'), 1_000)
  ctx.effect(() => () => clearInterval(timer))

  const controller = new AbortController()
  ctx.effect(() => () => controller.abort())
}

Verify cleanup rather than reviewing it visually. Mount the plugin in a focused runtime, observe one tick or registration, dispose its Fiber, wait beyond the interval, and assert that no second effect occurs. For a process, verify that disposal reaches the process tree and that no child remains. For a route, verify that the handler disappears or returns to the previous owner.

text
mount → observe one registration → dispose Fiber
      → wait beyond resource interval
      → assert no listener, timer, route, or process remains

Give the plugin a validated configuration contract

Cordis configuration is data that drives mounting, not an untyped options bag. A plugin exports a Config type and Schema so invalid values fail before effects commit. Defaults belong in the schema or documented resolution path. Dynamic configuration must define whether a change can update facts in place or requires Fiber replacement. DeepSeek Harness generates a configuration catalog from package contracts, making field ownership inspectable.

typescript
export interface Config {
  intervalMs: number
  label: string
}

export const Config: Schema<Config> = Schema.object({
  intervalMs: Schema.number().min(100).default(1_000),
  label: Schema.string().default('heartbeat'),
})

A Cordis row names the plugin and supplies config. Rows have stable ids so later patch layers can target them. Remember that a Harness patch replaces the row's whole config. Preserve fields and runtime expressions you still require.

yaml
- id: example-heartbeat
  name: '@example/dsh-heartbeat'
  config:
    intervalMs: 2000
    label: evaluation-heartbeat

Success is a validated row in dump-config followed by one active Fiber. An interval below the schema minimum should fail configuration without leaving a timer behind. A valid watched patch should transactionally replace the old behavior and dispose its previous effect.

Mount the plugin through a profile instead of editing a core Bundle

Out-of-tree plugins belong in the profile directory. The CLI plugin command initializes the profile when needed and forwards pnpm arguments with that directory as cwd. Dependencies whose manifest declares a dsh Bundle join the profile's Bundle stack; a plain plugin dependency remains available for a patch row. Relative specs are anchored to the invoking directory first, so adding a local checkout installs the checkout you are standing in.

bash
cd /path/to/dsh-heartbeat
dsh plugin --profile web add .
dsh --profile web --dump-config > /tmp/web-with-heartbeat.yml
grep -n "example-heartbeat" /tmp/web-with-heartbeat.yml

If a Git-hosted source package needs a prepare build, pnpm may reject it until allowBuilds names the reviewed package. Follow the exact key printed by pnpm in the profile's workspace configuration, then rerun. Do not enable arbitrary dependency scripts. After installation, add or verify the Cordis row in the profile patch and inspect the complete resolved config before boot.

bash
dsh plugin --profile web why @example/dsh-heartbeat
dsh --profile web --dump-default-config > /tmp/default.yml
dsh --profile web --dump-config > /tmp/resolved.yml
diff -u /tmp/default.yml /tmp/resolved.yml

Test lifecycle, contract, configuration, and composition separately

A useful plugin test suite has four layers. Contract tests verify public types and validation. Lifecycle tests mount and dispose the plugin while checking reversible effects. Integration tests mount a real Provider and Consumer in one Context. Composition smoke tests load the actual Cordis row and prove the profile resolves it. A single direct call to apply does not cover dependency waiting, transactional replacement, or profile visibility.

text
Contract: invalid interval → validation error, no effects
Lifecycle: mount → tick → dispose → no later tick
Service: provider visible → consumer receives contract result
Composition: dump-config contains one row → profile boots → row active

Troubleshoot by symptom. A missing service means the Provider is absent, pending, or outside the consumer's Context. Duplicate events after patch reload indicate leaked effects. A waterfall chain stopping early indicates a listener did not delegate. A configuration flag being ignored often means a patch replaced an expression-bearing config with literals. A plugin installed but not mounted needs a Cordis row or Bundle declaration.

bash
dsh --profile web --dump-config > /tmp/resolved.yml
rg "ctx\.greetings|GreetingService" packages docs
rg "example-heartbeat" /tmp/resolved.yml /path/to/profile/cordis.patch.yml

Review the plugin as a composable runtime participant

Before shipping, trace every dependency from Definition to mounted Provider, every registration to its owning Context, every resource to cleanup, every configuration field to validation and documentation, and every event to its declared dispatch mode. Exercise patch replacement and disposal under the profile that will host the plugin. Inspect the resolved tree rather than relying on the source Bundle alone.

Also review model experience. A host-only service may contribute no prompt tokens or events. A model-facing tool or runtime-context contribution changes what the model sees and must be durable when replay requires it. The architecture requires model-visible inputs to be logged and reconstructable. Do not smuggle transient global state into a model request.

text
Release checklist
[ ] Definition is stable and provider-neutral
[ ] Provider validates before registration
[ ] Consumer declares injection
[ ] Event modes match dispatch sites
[ ] Effects unwind on partial and normal disposal
[ ] Profile dump shows exactly one intended row
[ ] Model-visible facts are durable and replayable
[ ] Failure tests leave no timer, process, route, or listener

Developer preview means Cordis and Harness contracts can evolve incompatibly. Pin the tested revision, publish source maps to exact official documents, and include a rollback that removes the profile row and dependency. Composability makes rollback clean only when lifecycle ownership is correct.

Official sources