M7.8
Versioning and distribution
3,606 words · about 23 min
Concept
An extension point is a promise. Once a third party depends on ctx.shell's shape or tools/pre-execute's dispatch mode, changing either breaks code you cannot see. The failure this discipline prevents is the one that kills plugin ecosystems: authors stop updating because every release breaks them, users stop installing because plugins are always broken, and the ecosystem dies while the core team is still shipping features.
Mechanism: distribution
MechanismFrom docs/user/develop/basic/publish.md, a bundle is a directory:
hello-plugin/
├── package.json # declares dsh.bundle
├── cordis.patch.yml # the layer applied when a profile lists this bundle
└── index.js # plugin modules the patch rows reference{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}- insert:
- id: hello
name: dsh-hello-pluginNote the patch row names the package, not a relative path, so Node resolution finds installed code. Installation:
dsh plugin --profile demo add ./hello-plugin
dsh --profile demo --dump-config # shows a "# == dsh-hello-plugin" layer
dsh --profile demodsh plugin forwards to pnpm in the profile directory, so every pnpm verb works, then reconciles dsh.profile.bundles against installed state (apps/cli/reference/README.md): a dependency whose manifest declares the bundle field joins the layer stack — "so an update that gains the declaration activates it" — a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. The graceful-degradation rule matters: "A package without the dsh.bundle declaration still installs, but only as a plain dependency: dsh plugin prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable." A shared library is not a bundle, and the manifest is where that difference lives.
Mechanism: the supply-chain honesty
MechanismThe tutorial's most valuable section is the git-install catch, and it is a security lesson dressed as a build note.
A git install fetches sources, not built artifacts, so a TypeScript package arrives without lib/ and fails to load. The author ships a prepare script that builds self-contained from source. And the user must allowlist it, because "pnpm ≥10 refuses to run a git dependency's prepare script until it is explicitly allowed":
allowBuilds:
dsh-hello-plugin: trueThen the framing that every extension ecosystem should copy verbatim:
Treat that allowance as what it is: permission to execute the package's code on your machine at install time, outside any sandbox the agent runs under. Only allow packages whose source you trust, and pin a commit (
github:you/hello-plugin#<sha>) so a later push cannot silently change what runs.
Install-time code execution is the highest-value supply-chain attack surface in the JS ecosystem, and it is outside every runtime sandbox the harness has. dsh points at the two alternatives that need no allowance — publish to npm with lib/ built at publish time, or ship a pnpm pack tarball — and recommends pinning by SHA when neither applies. Notably, the CLI ships @deepseek-ai/dsh-mcp-client as a dependency "but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox" (apps/cli/reference/README.md) — the same reasoning, applied consistently.
Mechanism: what `dsh` actually promises
MechanismHere the honest answer is: less than a mature ecosystem needs, and deliberately.
AGENTS.md records the pre-release stance: dsh-session keeps SESSION_FORMAT_VERSION at 0 "with no compatibility promise"; SQLite uses a monotonic SCHEMA_VERSION. Every workspace package is private: true with a version matching the root (docs/cookbook/adding-a-package.md), so internal packages version as one unit rather than independently.
This is a legitimate pre-1.0 choice — "foundation over blast radius" — and it is stated rather than hidden. But a CAHE holder should read the implication clearly: dsh's extension points are currently stable by convention and documentation, not by a versioning contract. A third-party plugin depends on the shapes in docs/capability-seams.md and docs/event-producer-consumer.md, and those are generated from source, meaning they are accurate but not frozen. The generation is genuinely valuable — a drifted doc is worse than none — but generated accuracy is not a compatibility promise.
The one place dsh versions rigorously is its framework layer, and the technique transfers. vendor/README.md:
This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned).
The manifest pins every package to an upstream repo and commit SHA — cordis 4.0.0-rc.7 at 56b3d4f7…, loader 1.0.0-rc.5 at the same commit, and so on — and the local-modification log carries the instruction "Keep this log exhaustive — every divergence from upstream must be listed," followed by eighteen numbered entries with rationale (reentrant-disposal hardening in fiber.ts, transactional Loader/Include reconciliation, lazy config resolution porting a specific upstream PR).
That is the transferable practice: when you depend on a framework you must be able to patch, vendor it, pin it by SHA, and keep an exhaustive divergence log — so that a future maintainer can answer "is this upstream behaviour or ours?" without a bisect. The cost is owning the sync, and the hygiene gate verify-vendored-links exists to keep the pins honest.
Managing breaking changes: the practices worth carrying
From what dsh does and documents, the checklist that generalises:
- Version the wire, not just the code.
SESSION_FORMAT_VERSIONandSCHEMA_VERSIONexist as separate, explicit numbers because persisted format and code version break independently. - Make the compatibility surface enumerable.
docs/capability-seams.mdanddocs/event-producer-consumer.mdare generated from the TypeScript program, so "what did we promise?" is a query rather than an argument.verify-cordis-catalogand friends gate freshness. You cannot manage a compatibility surface you cannot list. - Treat dispatch mode as breaking. Changing
tools/pre-executefrom waterfall to emit silently removes every listener's veto.AGENTS.mdrequires@modein event JSDoc precisely so the mode is contract rather than implementation. - Fail loud on stale references. "Misconfiguration fails loud at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent." A patch naming an absent row is a stderr warning; a bundle-less package in the bundle list fails outright. Silence is the enemy — an extension that quietly stops applying is worse than one that refuses to boot.
- Brand cross-boundary ids. "Opaque cross-boundary ids are branded (
Branded<B>fromdsh-brand), never barestring." A brandedSessionIdcannot be accidentally passed where aCordisDynamicPluginIdbelongs, at zero runtime cost. - Distribute built artifacts. Prefer npm or a tarball over a git install so users never grant install-time execution.
- Pin by SHA when you must take source. For git installs and vendored code alike.
Tradeoffs
Strong compatibility promises slow core evolution — every seam change becomes a migration project. Weak promises accelerate the core and kill the ecosystem. dsh is currently at the fast-evolution end, which is correct for a pre-1.0 reference implementation and would be wrong for a product with third-party plugin authors. The transition between those postures is one of the harder moments in a platform's life, and it is worth planning before it arrives rather than during.
How others differ
VS Code publishes an engines.vscode range per extension and maintains a proposed/stable API split, so extensions declare compatibility and unstable APIs are opt-in. That is the most mature model in the space and it costs a dedicated team. MCP versions the protocol with negotiation at connection time, so client and server can disagree about version and still talk — the right shape for a wire protocol and unavailable to in-process plugins, which share a type system. Claude Code keeps its extension surfaces (hooks, MCP, skills, commands) narrow and additive, which is the pragmatic middle: fewer promises, so fewer breaks, at the cost of the deep replaceability dsh offers.
Hands-on lab
Goal: add a capability by mounting a plugin, without editing any existing package — proving the M7.2 claim, then exercising M7.3 and M7.5.
Part 1 — a native interception plugin (M7.1, M7.4)
Write a plugin that denies any bash tool call whose command contains a configured pattern, using the extension-cookbook shape:
export const name = 'command-denylist'
export const Config = /* schemastery object with a `patterns: string[]` field */
export function apply(ctx: Context, config: Config) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
// inspect exec; return { kind: 'deny', reason } or delegate with next()
})
}Mount it with a --patch overlay containing one insert row with a config.patterns list.
Success criteria.
- A matching command is denied with your reason; a non-matching command runs normally.
- No file outside your new package was modified.
- Patterns come from
config, not a constant — theAGENTS.mdno-hardcoded-tunables rule. - Removing the overlay row fully restores prior behaviour with no residue.
Stretch. Deliberately omit next() on the allow path. Observe that every tool call is now blocked without a deny message — the short-circuit failure from M7.1. Then fix it.
Part 2 — a provider swap (M7.2)
Without editing tool-bash, change which shell executor the composition uses by editing one row's name. Confirm via --dump-config that only the executor row changed, and confirm the tool still works.
Success criteria. The consumer package is untouched; the swap is one line; the tool's behaviour changes as expected.
Stretch. Try mounting two ctx.shell providers at once. Confirm it fails loud at load rather than silently choosing (M7.3).
Part 3 — layer inspection (M7.3)
Given a profile, use dsh --profile <name> --dump-config to answer:
- Which layer supplies each row of the effective config?
- What happens to a row's other config keys when a later layer patches one of them?
- What does a patch targeting a non-existent id produce?
Success criteria. You can state the layer order from memory and predict a patch's effect before running the dump. You have observed whole-config replacement rather than deep merge, first-hand.
Part 4 — skill vs tool (M7.5)
Author a skill as <projectRoot>/.dsh/skills/<name>/SKILL.md with kebab-case name and frontmatter description. Then, for each of these five, decide skill / tool / hook / command and write one sentence of justification:
- Query an internal metrics API.
- The team's release procedure across six repos.
- Reject any tool call writing outside the workspace.
- Let a user switch the active model mid-session.
- Explain how to interpret this codebase's error-code taxonomy.
Success criteria. Your answers are 1: tool, 2: skill, 3: hook, 4: command, 5: skill — and each justification names the deciding property (needs code / is procedural knowledge / must be non-elective / is a human action that should not cost a turn), not just the outcome.
Stretch. Measure it. Compare tokens for your skill body in the system prompt versus in the catalog, and compute the break-even invocation rate at which progressive disclosure stops paying.
Common pitfalls
1. Forgetting next() in a waterfall listener. The failure: your listener returns a value on the allow path without delegating, silently short-circuiting every listener registered after it. Because the return type is the same either way, the compiler cannot help. Symptom: an unrelated plugin's policy stops applying and nothing logs a reason. Fix: return next() on every path that is not a deliberate decision, and remember that short-circuiting is the design for a single-decision point — the bug is doing it accidentally.
2. Assuming an emit point can veto. The failure: you attach a permission check to agent/session-start or subagent/end and it appears to work in tests because nothing tried to proceed concurrently. Emit listeners are not awaited and have no return channel; a SessionStart hook "cannot block." Fix: read the @mode tag before choosing a point. Enforcement goes on a waterfall or serial point, full stop.
3. Patching a row and losing its other config. The failure: your profile patch sets timeoutMs on a row and every other key on that row silently reverts to the plugin's schema default, because "a patch replaces a row's whole config" with no deep merge. Fix: restate every key the row needs, and verify with --dump-config before shipping.
4. Publishing a process-global service from a preset. The failure: a preset row registers into the root service realm; the first session works, the second collides, and a host reader resolves one preset's instance for every session. Fix: put a preset-owned service behind an isolate realm, or move it to the host composition. dsh rejects this at mount — and re-checks on every service notification, because a row publishing from a timer escapes a one-shot audit.
5. Building a skill where a tool belongs (or vice versa). The failure mode for skill-as-tool: prose instructing the model to construct psql invocations through bash — unvalidated, brittle, unauditable. For tool-as-skill: a monolithic run_release_checklist() where partial completion is unrepresentable and the model cannot exercise judgement between steps. Fix: apply the decision order. Needs code the model cannot reach → tool. Procedural knowledge over existing tools → skill.
6. Treating a model-elective surface as an enforcement point. The failure: a "security policy" written as a skill, or a validator exposed as a tool the model may decline to call. Both are advisory. Fix: enforcement goes on tools/pre-execute (or ctx.tools.guard() when the invariant needs a monotonic final denial), where the model has no say.
7. Assuming a bridged hook config runs faithfully. The failure: a user's .claude/hooks.json works in Claude Code and silently no-ops in dsh because its event is one of the 23 unsupported ones, or because allow does not pre-approve, or because updatedInput is parsed but not honoured. Fix: check the bridge's mapping table and its limitations section before promising a user their hooks work; treat the bridge as a migration path, and write anything bespoke as a native plugin.
8. Registering without a disposer. The failure: your plugin's tool, listener, or prompt section survives its own unload, so hot reload accumulates duplicates and preset teardown leaks across sessions. Fix: every contribution through ctx.effect() / ctx.on(); hold a registry's returned disposer. docs/cordis-primer.md adds the ordering rule: "If teardown order matters, keep the related work in one effect so disposal unwinds in the intended sequence."
9. Allowing a git dependency's build script without reading it. The failure: allowBuilds grants install-time code execution on the developer's machine, outside every sandbox the agent runs under, and an unpinned git ref means a later push changes what runs. Fix: prefer npm or a tarball; when you must take a git install, read the prepare script and pin the commit SHA.
Key terms
- Plugin — an object implementing Service: a function with optional
injectandapply(ctx)fields, or aServicesubclass. - Context (
ctx) — a repository of services, each claiming a stablectx.<key>. - Capability seam — a capability split into Service Definition, Service Provider, and Consumer roles. Complete, never one role.
- Service Definition — the package owning the interface, vocabulary types, and capability-level constants; says what, never how.
- Service Provider — one implementation of a definition, registered on the definition's
ctxkey. - Consumer — the user of a capability, commonly a model-facing tool.
- Capability fact — a neutral value on a definition (e.g.
sandboxMode) that lets a consumer adapt to a provider without importing it. - Dispatch mode —
emit/waterfall/parallel/serial; part of an event's public contract, documented with@mode. Determines whether listeners can veto. - Waterfall — around-middleware dispatch; a listener receives
(...args, next)and must callnext()to delegate. Returning without it short-circuits. - Effect — a reversible registration created with
ctx.effect()/ctx.on(), returning a disposer. - Bundle — an npm package shipping a config layer, declaring
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }. - Profile — a directory under
$DSH_HOME/profiles/<name>describing one runnable composition, declaringdsh.profilewith an orderedbundleslist. - Patch layer — an id-targeted overlay that replaces a row's whole
configorinserts new rows. No deep merge. - Agent preset — a directory holding one
agent.cordis.yml, mounted per process under a standing scope that sessions join, giving one session its own tools and prompt sections. isolaterealm — the entry-local service realm a preset-owned service must use so two presets' same-named services do not collide.- Hook bridge — a plugin translating an external shell-hook protocol (Claude Code's or Codex's
hooks.json) onto the harness's native interception points. - Native hook — an ordinary Cordis plugin listening on an interception point; needs no external protocol and writes no
hook/*records. - Most-restrictive merge — folding many hook outcomes into one:
deny > ask > allow, halt sticky on the firstcontinue:false. - Progressive disclosure — showing the model a minimal catalog (
name+description) and loading full content only on demand. - Skill — an on-demand instruction body with a catalog summary and an invocation policy; extends what the model knows how to do.
- Human command — a plugin-registered slash command executed against an agent without creating a model message; its result never enters model history.
- Dynamic package — a model-defined Cordis package evaluated in-process, session-scoped, never persisted, and requiring a separate run verb after definition.
Summary
What should have changed in your mental model:
"Adding a capability" stopped meaning "editing the loop." The Module 1 spine's structural flaw was not that it was small but that it was the only place capability could live. Once every part is a plugin — including the loop — there is no privileged core to patch, and a third party extends by mounting beside you rather than forking you.
A seam is three roles, not an interface. Definition, Provider, Consumer. The Definition's job is to make providers indistinguishable at the boundary; anything a consumer might branch on is either uniform or a declared capability fact. If adding a provider requires editing a consumer, you do not have a seam.
Dispatch mode is the contract. An emit point is observation; only waterfall and serial points can veto. Choosing a mode is choosing whether extension authors get a say, and changing it later silently disarms every listener.
Composition is data, and layering is a policy decision. Bundles, profiles, patch overlays, and per-session presets are one mechanism at four scopes. Whole-config replacement instead of deep merge buys diagnosability at the cost of verbosity — a defensible trade, stated as one.
Interop with a foreign protocol is a migration path, not an architecture. dsh's hook bridges are a genuine gift to users with existing configs and carry a documented fidelity gap — 23 of 30 CC events unmapped, allow that does not pre-approve, updatedInput parsed but not honoured. The right posture is the one dsh states itself: bespoke behaviour belongs in a native plugin on the typed points.
Skill, tool, hook, and command are four answers to four different questions. Needs code the model cannot reach → tool. Procedural knowledge over existing tools → skill. Must run whether or not the model chooses it → hook. A human action that should not cost a model turn → command. The dividing line that matters most: skills and tools are model-elective; hooks are not.
Progressive disclosure is context budgeting applied to instructions. The catalog carries name and description only, and the type system enforces the omission by putting content on SkillDefinition alone. It wins when relevance is sparse and loses when it is universal.
Self-modification is capability, not privilege — if you are honest about the sandbox. Define and run are separate verbs, packages are immutable and session-scoped, nothing persists across restart, and node:vm is documented as "not a security boundary." Enable it where the agent already has shell; do not enable it as a way to grant shell.
An extension point is a promise, and promises need an enumerable surface. Generated seam and event maps make "what did we promise?" a query. dsh currently promises little and says so; the discipline it does apply rigorously — vendoring by SHA with an exhaustive divergence log — is the part to carry into any codebase you own.
Further reading
In the dsh repository — read in this order:
AGENTS.md— the plugin thesis and the conventions this module cites throughout.docs/cordis-primer.md— the framework contract: five ideas, dispatch modes, waterfall semantics, loader configuration.docs/architecture.md— the turn flow and the "Where new behavior goes" table.docs/capability-seams.md— generated; every seam with its role, owner, implementations, and consumers.docs/event-producer-consumer.md— generated; every event with mode, declaration site, dispatchers, and listeners.docs/cookbook/extension-cookbook.md— plugin shapes plus the feature → mechanism map.docs/cookbook/adding-a-package.md— the package checklist and the role-naming table.docs/cookbook/adding-an-llm-adapter.md— a provider's obligations, worked on thectx.llmseam.
Per objective:
- M7.2 —
packages/shell/README.md,packages/shell/shell/README.md. - M7.3 —
examples/headless-agent/cordis.yml,examples/web-cordis/cordis.yml,packages/boot/app-boot/README.md(§ Profiles),packages/bundle/README.md,packages/bundle/base/README.md,packages/preset/README.md,packages/preset/agent-presets/README.md. - M7.4 —
packages/hooks/README.md,packages/hooks/hook-protocol/README.md,packages/hooks/hook-protocol/src/types.ts,packages/hooks/hooks-claude-code/README.md,packages/hooks/hooks-codex/README.md. - M7.5 —
docs/subsystems/skills.md,packages/skill/. - M7.6 —
docs/subsystems/commands.md,packages/interaction/commands/README.md. - M7.7 —
docs/subsystems/extensions.md,packages/extensions/README.md,packages/extensions/tool-cordis/README.md. - M7.8 —
docs/user/develop/basic/publish.md,apps/cli/reference/README.md,vendor/README.md.
External:
- Claude Code documentation on hooks, skills, slash commands, and MCP — the reference for the format
dshbridges. - OpenAI Codex hook documentation — the deliberate subset, and a case study in protocol reuse.
- Model Context Protocol specification — a capability seam expressed as a wire protocol, with version negotiation
dsh's in-process seams cannot use. - VS Code Extension API, particularly
enginesranges and the proposed/stable API split — the most mature extension-versioning model in wide use. - OSGi's service registry and versioning model — the ancestor of this design space, and its cautionary tale about compatibility complexity.
Check yourself
1 question on M7.8