What Cordis Does Inside DeepSeek Harness

Source: Cordis 在做什么:从 DeepSeek Harness 看 by Antinomie on antinomie.org. The piece is the site’s current homepage post.

DeepSeek Harness (DSH) is an agent harness built on Cordis. The first thing that clicks is not a privileged core loop. Model adapters, tool registries, session logs, and the agent loop itself are all plugins.

This is the first post in my DeepSeek Harness series. I am starting with a close reading of how DSH actually uses Cordis: services, inject, reversible ctx.effect registration, Loader, and the config layers that turn composition into data.

Version note: Code citations pin DeepSeek Harness master @ 47f943859b. Cordis is the vendored source snapshot @deepseek-ai/[email protected] in vendor/cordis/package.json.

Cordis has a theoretical background in A Programming Paradigm for Spatiotemporal Composability (PDF; cited as “paper section x.y”). This post does not unpack the theory. It uses the mechanisms to answer one question: how does DeepSeek Harness use the features Cordis provides?

Every code citation keeps file:line and links to the GitHub source at that commit.

1. Premise: Everything Is a Plugin

DSH is an agent harness.

Its first principle is: model adapters, the tool registry, session logs, and the agent loop itself are all Cordis plugins. There is no privileged core. So “how DSH uses Cordis” and “how DSH is built” are the same question.

The second principle is about how packages are split: one capability becomes three packages. The Definition package defines the service. The Provider package supplies an implementation. The Consumer package uses the capability. Providers attach an implementation to a service name in two ways:

  • Exclusive: the provider occupies the service name. Only one implementation is live at a time. Shell works this way: bash-sandbox occupies ctx.shell (section 3). A second implementation throws at provide time (the duplicate check in section 5). Which implementation is running is not a runtime race. Configuration decides it statically. dsh --profile web --dump-config shows the stacked tree that actually takes effect.
  • Registry-style: several implementations coexist under one capability. The Definition package holds a registry. That registry is not a framework feature. It is just a Map on the service, for example WebRuntime‘s private searchProviders = new Map<string, WebSearchProvider>() (packages/web/web/src/index.ts:85). “Register into the registry” means the provider calls a register method and lands in that Map (store.set in section 5). The registry absorbs high-frequency implementation changes: swap one implementation and you only undo one entry and add another. Plugins that depend on the service stay still.

Take the shell capability that will keep showing up:

  • Definition: @deepseek-ai/dsh-shell (ShellExecutor plus a type patch, unpacked in section 3)
  • Provider: @deepseek-ai/dsh-bash-sandbox (SandboxBashExecutor in section 6, plus bash-local and the pwsh family)
  • Consumer: @deepseek-ai/dsh-tool-bash, which declares inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] (packages/shell/tool-bash/src/index.ts:31), wraps shell as a bash tool the model can see, and calls ctx.shell.run(...) at line 380

Provider and consumer never import each other. Both only know the Definition package and the service name. Swap the implementation and the consumer does not change a line.

flowchart TB
    Def["Definition
@deepseek-ai/dsh-shell"] Prov["Provider
dsh-bash-sandbox / bash-local / pwsh"] Cons["Consumer
dsh-tool-bash"] Def --> Prov Def --> Cons classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff class Def blueClass class Prov orangeClass class Cons greenClass

I have to admit: because DeepSeek Harness needs a plugin tree with spatiotemporal independence, the framework code is expensive to understand. As a developer, we only need the mechanisms. And after this article, the surprise is that most of that complexity is eaten by DeepSeek Harness itself. Adding a plugin does not add extra complexity.

2. One Boot: Five Lines, Every Concept

Run dsh --profile headless "fix this bug" and you land in boot() (packages/boot/app-boot/src/index.ts:757-784, error handling omitted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export async function boot(
binName: string,
absoluteConfigPath: string,
// ...
): Promise<Context> {
const ctx = new Context()
// ...
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
// ...
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
await ctx.get('loader')?.await()
if (ctx.get('loader') === undefined) return ctx
await assertEntriesActivated(ctx, binName)
return ctx
}

Those five lines map to five Cordis mechanisms:

  • new Context(): create the root of the plugin tree. Context is both the service store (every later ctx.xxx is registered on it) and a tree node. Each mounted plugin gets its own child context. Unload the parent and the subtree unloads with it.
  • ctx.provide('dshHomePath', ...): the first service registration. Hang a value on ctx so the whole system can read it.
  • ctx.plugin(Loader): programmatically mount the first plugin. Loader reads YAML config.
  • mountRootInclude(...): hand cordis.yml (plus patch layers, section 7) to Loader, which expands it declaratively into the full plugin tree.
  • await ctx.get('loader')?.await() plus assertEntriesActivated(...): wait until the tree is in place, then audit. If an entry never activates (for example a declared dependency will never exist), fail here and name what is missing. Do not run with a half-loaded tree.
flowchart LR
    A["new Context()"] --> B["provide dshHomePath"]
    B --> C["plugin Loader"]
    C --> D["mount cordis.yml"]
    D --> E["await + audit"]

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
    classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
    classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff

    class A blueClass
    class B orangeClass
    class C greenClass
    class D purpleClass
    class E tealClass

The next three sections unpack the three most important mechanisms on this tree: services, dependency declarations, and reversible registration. Section 6 looks at a plugin as a whole. Section 7 covers the config layers.

Plugin: the unit of loaded code

A plugin is the unit of loaded code: an inject declaration plus a block that runs after its dependencies are ready. That block has two shapes:

  • A function plugin exports apply(ctx, config).
  • A class plugin (extends Service) has no apply. Its constructor is that block.

Both receive the same (ctx, config). A plugin has a lifecycle: instantiate on mount, reclaim everything it registered on unload.

Service: an object shared by name

A service is an object one plugin shares with the rest of the system by name. The provider uses provide to bind an object to a service name ('web', 'shell', 'llm'). The consumer reads the same name as ctx.web.

The object can be anything. dshHomePath is just a string. loader is an instance with methods. After Loader is mounted with ctx.plugin, its constructor binds itself to the loader service name (vendor/loader/src/index.ts:90; a class plugin’s constructor is the startup code, see section 3):

1
ctx.reflect.provide('loader', this, this[Service.check])

The only shared contract is the service name. web-search-exa declares that it needs web. It does not know or care which plugin provides the object.

If the provider is not loaded yet, reading the name returns nothing. If the provider is unloaded, the binding disappears. “I need this service” is not the same as “I can read it now.” That is why we need inject: declare which services a plugin needs to start, then wait until those services are in place. That is section 4.

And “wait until ready” is not a one-shot. If a running service is swapped (for example the shell implementation plugin changes from bash-local to pwsh), Cordis reloads every plugin that declared it needs shell and re-runs apply on the new tree. If a service loses its provider, those plugins are stopped first. Dependencies always point at the provider that is currently in effect, not the one that existed at first boot.

Why reload instead of quietly handing the new object to a running plugin? Because apply already ran. The old implementation may already be captured in event listeners or timer closures. Swap only the object and the plugin still holds the old reference. Undo all of its effects and re-run apply on the new implementation. That is the only consistent way to land the whole plugin on the new implementation.

Why can reload happen at any time? Because every effect is reversible. There is no half-success, half-failure middle state. That is section 5.

Keep two layers of change distinct. The example above swaps the service provider, which triggers reload. When a registry-style provider such as web-search-exa is swapped (sections 4 and 5), no plugin reloads. The registry undoes one entry and adds another. The next ctx.web.search() follows the selection rules onto the new provider. That is the point of the three-package split in section 1: high-frequency provider changes stop at the registry layer and do not disturb plugins that depend on the service.

The plugin tree: who mounts whom

The plugin tree is the set of “who mounted whom” edges. There are two ways to declare those edges. Start with the programmatic one.

Programmatic: ctx.plugin(X) mounts X as a child of the ctx at the call site. X’s apply receives that new child ctx. If X then calls ctx.plugin(Y) inside its own apply, Y is X’s child. agent-spine in section 4 fires about twenty ctx.plugin calls in a row and produces a subtree with twenty-odd children.

Mount and start are separate actions.

Here is everything ctx.plugin(X) does (vendor/cordis/src/registry.ts:316-330, RegistryService.plugin, return wrapping omitted):

1
2
3
4
5
6
7
8
9
10
11
12
13
plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
const callback = this.resolve(plugin) // function / class / { apply } object, unified into one callback
if (!callback) throw new Error('invalid plugin, ...')
this.ctx.fiber.assertActive()

let runtime = this._internal.get(callback) // registry for this plugin; fibers is a list
if (!runtime) {
// ...
runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
this._internal.set(callback, runtime)
}

const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)

Look at the last line: config and the inject declaration enter the fiber at this moment.

A fiber is the runtime object for one mount. It holds that mount’s config, inject declaration, child ctx, and lifecycle state machine. This post does not unpack the state machine. Remember two faces: it holds a disposal list used to undo effects (section 5), and it waits until inject dependencies are complete before starting (section 4).

Fiber’s constructor (vendor/cordis/src/fiber.ts:222-253,265, intercept bookkeeping omitted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
constructor(
public parent: Context,
config: any,
public inject: Dict<any>,
public runtime: Plugin.Runtime | null,
// ...
) {
this._config = config
// ...
this.ctx = this.context = parent.extend({ fiber: this }) // derive a child ctx

this._runner = {
epoch: INACTIVE,
execute: function () {
if (isConstructor(runtime.callback)) {
const instance = new runtime.callback(this.ctx, this.config) // ← the impl class is new'ed only at start
// ...
} else {
return runtime.callback(this.ctx, this.config)
}
},
collect,
}

this.dispose = parent.fiber.effect(() => { // register self as a parent-fiber effect → cascading unload
const remove = runtime.fibers.push(this)
// ...
})

The execute closure is only prepared. The constructor does not call it. The implementation class object is new‘ed only when the fiber starts (inject dependencies are complete, then execute runs).

That is the split:

  • Mount = build a fiber and hang it on the tree. Synchronous, immediate, and it does not run plugin code.
  • Start = run execute. Timing is decided by dependencies (section 4). agent-spine can write ctx.plugin(...) in any order because every mount finishes immediately. Who starts first is decided by dependencies, not by source order.

After the programmatic path, there is a second way to declare mount edges: declarative.

Declarative: an entry in cordis.yml with group: true has a config that is a list of child entries (in section 7, pty is nested under persistent-shell in the minimal preset). Loader instantiates the entry hierarchy into a tree of the same shape. “Unload the parent and the subtree unloads” is one sentence: a child instance registers itself as an effect of the parent instance (vendor/cordis/src/fiber.ts:265), and effects are reclaimed automatically when their instance unloads (section 5). Unload the parent and the whole subtree is reclaimed in cascade.

Why two ways? They face different people.

Declarative is for the orchestrator. That person manages the whole system and wants a data file they can edit line by line: disable a row, replace one row’s config, then hot-reload. To the orchestrator, agent-spine is one line in cordis.yml. How many child plugins it contains is not their problem.

Programmatic is for the component author. Inside a component you need “decide which child plugins to mount from my own config, and with what settings.” Literal YAML fields cannot express that. You write code.

Both paths produce the same tree. Children of a YAML group and children mounted by a bundle go through the same inject wait and the same effect reclaim.

3. Services: Where ctx.shell Comes From

In DSH, ctx.shell, ctx.llm, and ctx.web are not built into Cordis. Packages register them. Registration happens in the Service base-class constructor (vendor/cordis/src/service.ts:42,57):

1
2
3
4
5
constructor(protected ctx: Context, name: string) {
// ...
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}

A subclass calls super(ctx, 'shell') and registers itself as ctx.shell. Shell as an example (packages/shell/shell/src/index.ts:40,65):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
declare module '@deepseek-ai/cordis' {
interface Context {
shell: ShellExecutor
}
}

export abstract class ShellExecutor extends Service {
constructor(ctx: Context) {
super(ctx, 'shell')
}

abstract resolve(request: ShellExecRequest): ShellExecSpec
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
abstract start(spec: ShellExecSpec): ShellProcess
}

Two mechanisms sit in that snippet:

  • declare module '@deepseek-ai/cordis' (TypeScript module augmentation): add members to Cordis’s Context interface so every ctx.shell in the repo is typed. This is not a Cordis feature, but the Cordis service model depends on it for type checking. Every DSH service and event type is declared this way.
  • The abstract class is the contract: ShellExecutor is abstract. Implementation packages such as bash-local and pwsh subclass it, load as plugins, and occupy the name ctx.shell. Occupying the name is exclusive. Mount a second implementation and you get a duplicate-service error. Whichever row config selects is the implementation at runtime.

ShellExecutor is only the contract. The code that actually loads as a plugin lives in an implementation package, for example bash-local (packages/shell/bash-local/src/index.ts:95-112,122-123):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
* mechanics; this executor supplies their configured budgets per spawn, so a
* still-running background process stays managed (killed and joined at
* composition teardown) even across an executor reload.
*/
export class LocalBashExecutor extends ShellExecutor {
static inject = ['subprocess']

static Config: z<Config> = z.object({
cwd: z.string(),
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(64_000),
// ...
})
// ...
constructor(ctx: Context, config: Config) {
super(ctx)

The plugin’s whole obligation is those few lines: inherit the contract (the constructor chain ends at super(ctx, 'shell') and occupies the name), declare its own dependencies (it still needs the lower-level subprocess service), and accept config.

There is no apply. When a class plugin starts, Cordis runs new LocalBashExecutor(ctx, config). The constructor is the startup logic. config comes from the mount: the YAML entry’s config: field, or the config argument to ctx.plugin(X, config). The static Config above validates and fills defaults (timeoutMs defaults to 120 seconds in that schema).

How resolve / run / start are implemented is none of Cordis’s business. Cordis only tracks which plugin instance currently owns the name shell (empty, occupied by bash-sandbox, or swapped to pwsh-sandbox) and when each plugin’s declared dependencies are complete. The doc comment at the top of the quote is worth a pause: a still-running background process is killed and joined at composition teardown, “even across an executor reload.” The implementation author wrote the code under the assumption “I may be reloaded at any time.”

4. inject: Declare Dependencies, Infer Load Order

The second plugin mechanism is inject: declare “I need these services,” and Cordis waits until they exist before starting the plugin. A complete function plugin (packages/web/web-search-exa/src/index.ts:32,35,60):

1
2
3
4
5
6
7
8
9
10
11
12
export const name = 'web-search-exa'

/** The web seam this provider registers into. */
export const inject = ['web']

/** Register the Exa search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new ExaSearchProvider({
apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get('EXA_API_KEY')?.value ?? '',
// ...
}))
}

inject = ['web'] means: keep this plugin waiting until some plugin provides the web service, then run apply. The class-plugin equivalent is a static property (packages/core/agent-loop/src/index.ts:296-297):

1
2
3
/** Concrete agent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']

The direct payoff of “declare dependencies, start only when they exist”: you do not care about source order when composing plugins. DSH’s fullest example is agent-spine, a bundle plugin that mounts about twenty child plugins in one go (packages/examples/agent-spine-demo/src/index.ts:212-261):

1
2
3
4
5
6
7
8
9
10
export function apply(ctx: Context, config: Config): void {
// ...
ctx.plugin(Timer)
ctx.plugin(LlmRuntime)
ctx.plugin(SessionStore)
ctx.plugin(SystemPrompt, { /* ... */ })
ctx.plugin(ToolRuntime, config.tools ?? {})
// ...
ctx.plugin(AgentLoop, { agents: config.agents ?? [], /* ... */ })
}

The file-header comment states the semantics (lines 206-207): “Load order is irrelevant (cordis pends each fiber on its inject until the services it needs exist)”. The last line, AgentLoop, is the class with static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']. The llm, tools, and systemPrompt it declares are provided by earlier plugins in the list (LlmRuntime, ToolRuntime, SystemPrompt, …). Move it to the first line and it still waits until those dependencies exist. It sits last only for readability.

5. Registration Is an Effect: Where Unload Comes From

Inside one registration: where ctx.effect hides

The apply of that provider plugin in section 4 does one real thing:

1
ctx.web.registerSearchProvider(new ExaSearchProvider({ /* ... */ }))

That call enters WebRuntime.registerSearchProvider, which forwards to registerProvider in the same file. ctx.effect is hidden there (packages/web/web/src/index.ts:98-105,118-125):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* ...
* if its id is already registered for search. Returns a disposer; disposed
* with the calling fiber.
*/
registerSearchProvider(provider: WebSearchProvider): () => void {
return this.registerProvider(this.searchProviders, provider)
}

private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {
if (store.has(provider.id)) {
throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER')
}
const dispose = this.ctx.effect(function* () {
store.set(provider.id, provider)
yield () => store.delete(provider.id)
}, 'web.registerProvider()')
// ...
}

ctx.effect takes a “do” callback. The callback returns (or yields) an “undo” function. The generator body store.set(...) adds the provider to the registry. The yielded store.delete(...) is the undo. Two more details in the quote are worth naming. The second argument 'web.registerProvider()' is a diagnostic label for this effect. It shows up in getEffects() output (for example when you ask which effects an instance holds, or which undo failed). The default is 'anonymous' and it does not change behavior. The dispose returned by ctx.effect is wrapped and returned to the caller. Automatic execution on plugin unload is the backstop. The caller can also use that handle to undo this one registration early. web-search-exa does not take the handle. It relies on the automatic backstop.

Generators: ctx.effect drives them immediately

After ctx.effect receives the callback, it drives it immediately (vendor/cordis/src/fiber.ts:366,375-382, inside _execute, other return-shape branches omitted):

1
2
3
4
5
6
7
8
9
10
const effect: Effect = runner.execute.call(this)   // call the generator function, get an iterator
// ... (omitted: branches where the callback returns a function / Promise / async iterator)
} else if (Symbol.iterator in effect) {
const iter = effect[Symbol.iterator]()
while (true) {
const result = iter.next() // drive it to the end on the spot
safeCollect(result.value) // collect each yielded undo immediately
if (result.done) return
}
}

So store.set runs at the moment ctx.effect is called, and the undo function is collected at the same time. The returned dispose is a Cordis wrapper. Calling it runs every collected undo, and only once.

Undo: whoever registers, undoes

The key is who this.ctx points at. Look at that apply from section 4 again. This time watch the parameter and which object the property access happens on (packages/web/web-search-exa/src/index.ts:60-61, provider constructor args omitted):

1
2
3
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new ExaSearchProvider({ /* ... */ }))
}

The ctx that apply receives is web-search-exa’s own plugin ctx (the child ctx from section 2). The ctx.web access happens on that ctx. The WebRuntime you get back is not a bare object. Cordis wraps it and, at wrap time, remembers “which ctx is reading.” Methods called through that wrapper trace this.ctx back to it. So the undo function in registerProvider is registered on web-search-exa’s own plugin instance, not on the WebRuntime instance.

Contrast that with WebRuntime calling this.registerProvider(...) internally (for example to register a built-in provider during its own init). Then this.ctx is its own ctx, and undo belongs to its instance. Removing that entry would mean touching WebRuntime itself.

On the real path, undo belongs to the registrar: the entry lives in WebRuntime‘s table, but the function that removes it is kept by the web-search-exa instance. Each side owns its own concern. WebRuntime only holds the table. It does not need to know who put an entry in, and it does not clean up for anyone else. When web-search-exa unloads, the undo it registered (store.delete) runs automatically and the provider entry disappears from the registry. The registry holder’s lifecycle and the registrar’s lifecycle are not coupled.

The registrar never writes cleanup code. Unload itself is triggered by an orchestration action: delete that entry from cordis.yml, set disabled: true, or replace it with a patch. Loader disposes the matching plugin instance and runs every undo it registered.

“Registered on the instance” also has a concrete meaning. Each plugin instance has a disposal list. ctx.effect pushes the undo function onto it (vendor/cordis/src/fiber.ts:203, 520, Fiber):

1
2
3
4
public readonly _disposables = new DisposableList<Disposable>()   // each instance's disposal list

// inside Fiber.effect:
removeWrapper = this._disposables.push(wrapper) // "register" = push onto the list

On unload, clear the list and run each item (lines 675-676, inside _unload):

1
await Promise.all(this._disposables.clear().map(async (dispose) => {

Loader and this list split “trigger” from “execute.” Each entry holds a reference to the matching instance (entry.fiber). When a config change hits disable / delete / replace, Loader calls await fiber.dispose() once (vendor/loader/src/config/entry.ts:130-135, Entry._dispose). It knows nothing about what is on the list. fiber.dispose() starts the instance’s own unload path, and _unload above clears and runs the list. Loader decides when to dispose which instance. The instance decides what disposal does. The only seam is fiber.dispose(). That is how “config-driven unload” and “plugin authors write no cleanup” fit together.

Why a generator: hand over as you go

An ordinary function can only return one undo at the end. A generator can hand over as you go: finish one step, yield that step’s undo. The difference shows up only when something fails halfway. If a multi-step setup fails in the middle (or a dependency changes, or startup is aborted), the undos already yielded have been collected step by step, so completed steps can roll back precisely. A “return at the end” form has nothing to hand over on a mid-setup failure, so the work already done leaks. The generator can also be an async generator: steps may await (async setup) and undos are still collected incrementally. This example has only one setup step, so both forms are equivalent. The generator is DSH’s uniform habit.

A real multi-step example: SessionStore creates a session in two steps, persist then broadcast session/created (packages/core/session/src/index.ts:833-839; the comments are in the source):

1
2
3
4
5
6
7
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry and its publication hooks.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session) // step 1: persist, immediately hand over "remove it"
this.announce(session) // step 2: broadcast: a listener may throw
}.bind(this), 'sessions.create()')

Watch the order: the undo is handed over before the broadcast. The comment states the reason. If a listener throws, the already-yielded undo rolls the persist back, instead of leaving a dirty store entry with hooks attached. That is “hand over as you go” in production form. It is not a fix after failure. The code is written in the order “the next step might explode.”

Async and composition have a real example too. settings-file‘s [Service.init] is an async generator (packages/settings/settings-file/src/index.ts:232-269). Steps await (wait for path resolution, then start a file watcher) and yield* super[Service.init]() collects the base class’s undos into the same chain: the base class’s “load and publish settings” undo and the subclass’s “close the watcher” undo share one reclaim path.

The rule: every registration goes through ctx.effect

Actions like store.set that “put an object into a table another component holds, so the rest of the system can find it” (register a provider, an event listener, a tool, a service) are what DSH calls registration. It has a hard rule: every registration goes through ctx.effect(). At the framework layer that rule is literal. Look at the full provide implementation (vendor/cordis/src/reflect.ts:277-305, property-declaration bookkeeping omitted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
// ... (omitted: bookkeeping that declares name as a service)
this.ctx.root[symbols.isolate][name] ??= Symbol(name)
const key = this.ctx[symbols.isolate][name]
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
if (this.store[key]) {
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
}
this.store[key] = impl
this.ctx.fiber.store![name] = impl
if (this.ctx.fiber.state === FiberState.ACTIVE) {
this.notify([name])
}
return async () => {
delete this.store[key]
const fibers = this.notify([name])
await Promise.allSettled(fibers.map(fiber => fiber.await()))
// ensure self access before dependencies cleanup
delete this.ctx.fiber.store![name]
}
}, `ctx.provide(${JSON.stringify(name)})`)
}

The “do” side: write { name, value, fiber } into the store. If the name is taken, throw immediately. That is the exclusive name occupancy from section 3. If this fiber is already active, notify wakes plugins waiting on the service. The “undo” side is worth reading line by line: drop the name first, notify dependents, then await until they have all stopped, and only then clear the record on this instance. “Consumers stop first, provider withdraws second” is written here. So ctx.provide('dshHomePath', ...) in the section 2 boot, and provide in the section 3 Service constructor, all go through the same register-and-reclaim channel. Unload the provider plugin and the service name disappears. Plugins that declared a dependency on it stop. This rule is the foundation of two advanced DSH capabilities:

  • Config hot-reload: change a patch file, Loader unloads the old subtree and mounts a new one. The effect mechanism guarantees that services, tools, and listeners registered by the old subtree are all reclaimed. No middle state.
  • Plugin scope isolation: when a subtree mounted by a preset (section 7) unloads, every tool, prompt segment, and event listener it registered disappears automatically. No manual reconciliation.

Event listeners are not an exception. ctx.on (vendor/cordis/src/events.ts:288-301) calls register, and register is ctx.effect (254-259). A listener’s lifetime follows the plugin that registered it. Unload the plugin and the listener is removed. The waterfall extension points DSH builds on top of events (llm/stream, agent/pre-step, and so on) are DSH usage, not a new Cordis mechanism. This post does not unpack them.

6. How to Write a Plugin: Cordis Hides the Complexity

The first five sections took the mechanisms apart. This section asks what writing a plugin actually requires, and what a plugin’s lifecycle looks like.

Below is all the Cordis-related code a service plugin has to write. SandboxBashExecutor is the main class of the bash-sandbox package, the implementation that occupies ctx.shell in the base bundle (packages/shell/bash-sandbox/src/index.ts:44-45,67-72,182, sandbox business logic omitted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Context } from '@deepseek-ai/cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'

export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']

constructor(ctx: Context, config: Config) {
super(ctx, config)
this.mode = ctx.sandboxPolicy.defaultMode // a service declared in inject, used directly
}

override resolve(request: ShellExecRequest): ShellExecSpec { /* ... */ }
override async run(spec: ShellExecSpec): Promise<ShellRunResult> { /* ... */ }
override start(spec: ShellExecSpec): ShellProcess { /* ... */ }
// ... (sandbox business logic; Cordis does not care)
}

export default SandboxBashExecutor

Count what Cordis requires: inherit the contract (extends LocalBashExecutor, the chain reaches ShellExecutor), declare dependencies (static inject, two more services than the parent: sandbox and sandboxPolicy), and take (ctx, config) in the constructor, where ctx.sandboxPolicy is used directly because inject guarantees it is there. That is all. No register call, no unload code, no lifecycle hooks. resolve / run / start are pure domain logic. The trailing export default lets Loader find the class.

One question remains: after this class is mounted, what does the runtime do with it? That is the timeline below.

Step 0: the contract comes first. @deepseek-ai/dsh-shell defines the abstract class ShellExecutor (section 3): it declares the type of ctx.shell and three abstract methods. It provides no implementation.

Step 1: mount. The orchestration config has one entry (packages/bundle/base/cordis.patch.yml:178-182):

1
2
3
4
5
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
config:
timeoutMs: 60000

Loader reads it: import the package, create the plugin instance (vendor/loader/src/config/entry.ts:277-285, Entry._init: first this.parent.tree.import(this.options.name, ...), then _start(plugin) to build the instance). Mount is config-driven. At boot, Loader mounts when it expands to this row. Later config changes (hot-reload, patch, preset) also trigger it. The plugin never mounts itself. Notice the disabled expression: on Windows this row is not mounted at all. Its twin pwsh-sandbox (lines 184-186, opposite condition) takes its place.

Step 2: wait. The implementation class declares static inject = ['subprocess', 'sandbox', 'sandboxPolicy'] (the second line of the skeleton above). Until all three services are in place, the instance stays waiting. The constructor does not run.

Step 3: start. The three dependencies are ready. Cordis runs new (the class is the startup callback, sections 2 and 3). static Config validates config first: timeoutMs: 60000 goes through the schema here. The constructor chain reaches super(ctx, 'shell'), provide occupies the name (the implementation at the end of section 5), the instance activates, and notify wakes every plugin that declared it needs shell.

Step 4: service. A consumer (for example the tool-bash plugin) calls it through ctx.shell.run(...). Each call goes through the wrapper layer. this.ctx inside the method belongs to the caller. Registrations the consumer makes through it hang on the consumer’s own instance (section 5).

Step 5: unload. Config disables or deletes the row, or hot-reload rebuilds it. Loader calls fiber.dispose(). The disposal list runs LIFO. Still-running background processes are reclaimed (the “killed and joined” promise in the section 3 doc comment). The provide undo drops the name shell, notifies dependents, and waits for them to stop first.

Step 6: replace (if it happens). Change the entry’s name to another implementation package. The old instance takes step 5 and the name is vacated. The new instance takes steps 2-3 and occupies the name. Plugins that declared they need shell are reloaded onto the new implementation (the “continuous re-evaluation” in section 2).

flowchart TB
    S0["0. Contract first
ShellExecutor defines ctx.shell"] --> S1["1. Mount
Loader reads the YAML entry"] S1 --> S2["2. Wait
inject services are not all ready"] S2 --> S3["3. Start
new + provide occupies the name"] S3 --> S4["4. Serve
consumers call ctx.shell.run"] S4 --> S5["5. Unload
fiber.dispose, LIFO undo"] S5 --> S6["6. Replace
new provider, dependents reload"] classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff class S0 blueClass class S1 orangeClass class S2 purpleClass class S3 greenClass class S4 tealClass class S5 redClass class S6 orangeClass

Composition complexity is collected into the framework in one place. Cordis owns registration, dependency decisions, and reclaim. DSH owns the organizational discipline of “one capability, three packages (Definition / Provider / Consumer)” and static checks on config. What is left for a module author is domain code. So “add a module” has two tiers. Adding another implementation of an existing capability (another search backend, for example) is a few dozen lines of code plus one YAML row.

The cost that remains is opening a new capability. You must prepare the Definition package, the Provider package, and the Consumer package that faces the model or the user at the same time. That is a design decision, not just writing domain code. Those three packages already appeared in section 1: dsh-shell (contract), dsh-bash-sandbox (implementation), dsh-tool-bash (consumer).

7. Composition Is Configuration: cordis.yml, Patch Layers, and Presets

Loader describes the whole system as data. A cordis.yml is a list of entries. Each entry commonly has four fields: id (row identity), name (package name), config (passed to the plugin), and disabled (on/off). A real example (examples/headless-agent/cordis.yml:9,23,44):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- id: settings
name: '@deepseek-ai/dsh-settings-file'

- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
thinking: enabled
reasoningEffort: max
models:
- id: deepseek-v4-pro
contextWindow: 128000

- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'

The file’s only “programming” feature is !!js expressions, and they are allowed in only two fields: config (evaluated after that plugin’s dependencies activate, so it can read environment state) and disabled (evaluated on every mount decision, so a whole row can switch by platform). Every other field is a literal. Static checks (scripts/verify-cordis-config.ts) enforce that at commit time.

The tree that actually runs is not a single file. A patch is not a complete tree. It is a modification list addressed by id: each item locates an existing row and replaces that row’s config wholesale (no deep merge). The tree that takes effect is stacked from five patch layers, in order: each bundle’s cordis.patch.yml (in declaration order) → the profile’s own patch → machine-level $DSH_HOME/cordis.patch.yml (applies to every profile, so it overrides the per-profile layer) → CLI --patch → the telemetry switch. When the same row is written by more than one layer, the later write wins.

A real example. The base bundle mounts the HMR plugin (packages/bundle/base/cordis.patch.yml:19-22):

1
2
3
4
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
config:
root: ['.']

The headless bundle writes the same id: hmr and only disabled: true (packages/bundle/headless/cordis.patch.yml:12-15; the comment is in the file):

1
2
3
4
# The shared module-reload HMR row stays off; the launcher's watch-only
# fallback still keeps the user patch layers live until the run exits.
- id: hmr
disabled: true

In the profile’s bundle order, headless comes after base. Patches apply in that order, so HMR is off at headless runtime and the base row is not edited. To see which tree actually runs: dsh --profile web --dump-config.

Up to here, composition is process-level. cordis.yml is the base (layer one). Patch layers modify it (layer two). The whole process has one tree. The third layer is the preset, and it is session-level (apps/cli/config/agent-presets/*/agent.cordis.yml). A preset is an entry list like cordis.yml. The difference is scope. The cordis.yml tree is shared by the whole process, and its services are process-wide singletons. A preset hangs on one session. Different sessions in the same process can hang different presets: one runs “minimal,” one runs “create,” and they do not interfere.

That produces a hard constraint: service rows in a preset must go into a cordis:group with isolate. Otherwise two sessions collide on the same process-level singleton (the persistent-shell row in minimal mode below is this pattern). The CLI’s four modes (standard / PTC / minimal / create) are not a mode field and not an if/else branch. They are four shipped preset directories. Each directory has one composition YAML and one display-metadata file. The entire difference among the four modes is the difference among YAML rows:

Mode Preset directory What the YAML changes
Minimal minimal Persona occupies the whole system prompt (complete: true); few tools; per-session services sit in an isolate group
Standard standard Full coding agent; shell tool is bash or pwsh by platform
PTC code Standard plus one tool-presentation row with mode: code
Create cordis Standard plus self-modification tools and a composition skill
  • Minimal mode (minimal/agent.cordis.yml:8,18): persona uses complete: true to occupy the entire system prompt and mounts only a few tools. Per-session services go into a cordis:group with isolate, so two sessions do not collide on one process-level singleton:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: You are a helpful software engineer assistant.
complete: true

- id: persistent-shell
name: cordis:group
group: true
isolate:
terminals: true
config:
- id: pty
name: '@deepseek-ai/dsh-terminal'

isolate: { terminals: true } means: open a resolution space private to this group for the service name terminals. The pty plugin inside it will provide the terminals service (the PTY registry).

When the same preset is mounted once by each of two sessions, without this isolation the second mount hits the exclusive-name duplicate error at provide (section 5). With it, provide and read inside the group land in the private space. The outside world and the same kind of group in another session cannot see it. Each session gets its own PTY registry. (The true here means this entry is private. Loader also supports a string so several entries share one isolation space. DSH’s shipped config does not use that form.)

  • Standard mode (standard): a full coding agent. The shell tool is chosen by platform (standard/agent.cordis.yml:44-50; the same rows exist in the code preset):
1
2
3
4
5
6
7
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'

- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
  • PTC mode (code): a complete copy of standard plus one row (code/agent.cordis.yml:259-262). Tool presentation becomes “one run_code tool plus a generated TypeScript SDK.” The model writes programs that compose multi-step operations:
1
2
3
4
- id: tool-presentation
name: '@deepseek-ai/dsh-agent-tool-presentation'
config:
mode: code
  • Create mode (cordis): a copy of standard, plus self-modification (cordis/agent.cordis.yml:245,255): a toolset that reads, writes, mounts, and unmounts runtime plugins, plus a skill that teaches composition:
1
2
3
4
5
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'

- id: skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'

This last preset’s file header has a trust-boundary statement (lines 9-12): cordis_mount executes model-written JavaScript inside the runtime. A session running in create mode should be treated with the same caution as shell permissions.

flowchart TB
    subgraph Process["Process-level tree"]
        direction TB
        Y["cordis.yml base"] --> P1["bundle cordis.patch.yml"]
        P1 --> P2["profile patch"]
        P2 --> P3["DSH_HOME/cordis.patch.yml"]
        P3 --> P4["CLI --patch"]
        P4 --> P5["telemetry switch"]
    end

    subgraph Session["Session-level presets"]
        direction LR
        M["minimal"]
        S["standard"]
        C["PTC / code"]
        R["create / cordis"]
    end

    Process --> Session

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
    classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
    classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
    classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff

    class Y blueClass
    class P1 orangeClass
    class P2 greenClass
    class P3 purpleClass
    class P4 tealClass
    class P5 redClass
    class M blueClass
    class S orangeClass
    class C greenClass
    class R purpleClass

The mechanical result: adding a fifth mode does not require changing any TypeScript. Copy a preset directory and edit YAML. That is “composition is configuration” in the literal sense.

8. Mapping: Which Cordis Feature Each Usage Eats

Fold the first seven sections back onto Cordis features:

Cordis feature Usage in DSH
Context tree + service registration (provide) Everything is a plugin. ctx.shell / ctx.llm / ctx.web are all registered by packages (section 3)
inject dependency declaration + start when available Composing plugins does not care about order. agent-spine mounts about twenty children at once (section 4)
Reversible ctx.effect registration Provider registration and event listeners are reclaimed when the plugin unloads. Config hot-reload leaves no middle state (section 5)
Loader declarative composition cordis.yml + stacked patches (process-level), presets (session-level). Four modes are four YAML files (section 7)
isolate Per-session services in a preset go into an isolate group. Multiple sessions in one process do not interfere (section 7)

Paper section 1.2.2, “Self-Evolving Agent Harnesses,” lists this as a motivating example: the harness generates and replaces its own components while it keeps serving. DSH’s create mode is a productized slice of that scenario. The model edits its own runtime plugin tree through tools such as cordis_mount. It can do that safely because of every row above: dependency declarations decide when to start, effects decide how to unload cleanly, and Loader turns “the system shape I want” into readable, writable data.

9. Developer View: Why DSH Is a Meta Harness

The end of section 8 said create mode lets the model edit the runtime plugin tree directly. Next: why that capability holds, and what it means once it does.

A harness’s job is assembling the model’s context: system prompt, tool list, environment information. In the earlier sections, each of those fragments has a clear owner. persona owns prompt text (minimal mode in section 7 uses it to occupy the entire system prompt). systemPrompt owns prompt segments. tools owns the tool registry. Those fragments are all “effect registrations” from section 5: reversible, replaceable, hot-reloadable.

Changing harness behavior then becomes a local act. The article already has a ready example: swap the shell implementation from bash-local to bash-sandbox (the lead of section 6) and execution policy goes from unrestricted to sandboxed. The consumer tool-bash does not change a line (the mutual non-import in section 1). Hot-reload applies the change. No process restart (section 5). Want different file-access permission logic, a different search backend, or a different prompt organization? Same motion: write an implementation package, change one YAML row. You do not hunt for scattered handling points in glue code.

That workflow is isomorphic for humans and for agents. Each plugin’s inputs and outputs are verifiable: config goes in, registration comes out, behavior is measurable. So “debug how this piece of context works” can be handed to the agent itself: change a plugin, hot-swap, verify, close the loop. Create mode productizes that loop and attaches an explicit trust-boundary statement.

That is enough to say what a meta harness means. DSH does not prescribe what an agent looks like, because the agent loop itself is just one of the twenty-odd lines in the agent-spine list (section 4). What it provides is a composition mechanism. The shape of the harness is decided by configuration. What is fixed first is context structure: which system-prompt segments, which tools, how environment information enters. Code (plugins) fills that structure. You do not write the code first and let context structure emerge from it. Extension points are no longer hook callbacks scattered through the code. Everything is a plugin.

That leaves a lot of room to explore. I will keep watching what gets built on DeepSeek Harness next. (Yes, that last line is a small ad.)


This is Part 1 of the DeepSeek Harness series.

Core Concepts of Agentic Coding with Claude Code Context Engineering for Claude Code

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×