DEV Community

liuyuyan6100
liuyuyan6100

Posted on Originally published at blog.aiclawonline.website

Why Do DSH Plugins Need a Restart? Bundles, Patches, and Hot Reloading Explained

At the end of my previous article about connecting DSH to Agent Memory, I left myself two directions to explore: work through DSH's plugin loading and development process, or take the memory integration further. Whichever produced something first would become the next article.

This week, it is the plugins' turn.

Here is the answer I found in the source code: in DSH, installing a plugin and editing configuration are different operations. In the normal bundle installation path, a newly installed plugin belongs to a bundle layer. It will not be baked into the plugin list until the next startup, which is why a restart is required. The “save to apply” behavior described in the documentation reloads the user patch layer at the top of the stack. The two statements describe different layers. This article follows how I pulled those two mechanisms apart.

DSH is an extensible Agent tool runtime built on Cordis. System capabilities are split into pluggable plugins, and profiles, bundles, and patches determine the effective plugin list. If you are unfamiliar with it, article 02 in this series provides some background.

The reason for this detour was simple: I was still gathering material for the memory integration, while my plugin experiments had already produced a pile of questions. In the previous article, I described DSH as an “everything is a plugin” architecture. I believed it, and I had successfully installed and run a third-party plugin. But several things that happened during installation still did not make sense to me. This week, I went through the source to work them out.


Installing a real plugin raised questions before I could celebrate

The normal path for installing a third-party DSH plugin takes two steps:

pnpm dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0
# Press Ctrl+C, then restart
pnpm dsh web
Enter fullscreen mode Exit fullscreen mode

Archify is a skill-only plugin for drawing architecture diagrams. After installing and restarting, I asked it to draw a runtime architecture diagram in a new session, and it worked. Its built-in doctor check also passed all thirteen checks.

So far, so good. But two things bothered me.

First, why was a restart mandatory? Without restarting, the plugin did not take effect. It was 2026, and a modern plugin system still needed a process restart after an installation?

Second, the documentation said that the web profile supported live reloading: edit the configuration file, save it, and the change takes effect. A mandatory restart on one hand, immediate changes on the other. Those statements seemed contradictory unless they referred to different things.

Following those two clues into the source led to a more elegant answer than I expected.


What “everything is a plugin” really means: the plugin list is baked

DSH is built on Cordis. I used to interpret “everything is a plugin” as “all functional modules are plugins.” That is true, but it misses a deeper part of the design.

The source shows that the plugin list DSH actually starts is not any single handwritten configuration file. It is the result of starting with an empty configuration and applying patches, layer by layer, in order. The layer-loading sequence is in packages/boot/app-boot/src/index.ts; that code also establishes the reloading boundary discussed later.

There are three concepts:

  • Entry: a plugin instance to start, represented as { id, name, config }.
  • Layer: a YAML patch file that adds entries to the list or modifies existing entries.
  • Bundle: an npm package that declares dsh.bundle. The patch file supplied by that package is the layer it contributes.

The layers baked into the web profile on my machine look like this:

Empty root configuration: []
  -> dsh-base
  -> dsh-web-app
  -> archify
  -> super-injector
  -> user patch: cordis.patch.yml (live HMR)
  -> --patch overlays (one-shot)
  -> final plugin entries
Enter fullscreen mode Exit fullscreen mode

What I had thought of as “the plugin configuration” was actually a view produced by applying patch layers to an empty configuration in order. An official command can print this layered view directly. Each section includes a # == package-name source comment, making it clear which layer contributed which entries.


Three patch operations, with rules that matter

A layer is an increment, not a complete configuration. Patches from all layers are flattened into a list and applied in order. There are only three operations. The patch algorithm lives in applyEntryPatches in vendor/include/src/index.ts; the four rules below come from its code comments.

  1. { insert: [...] }: append new entries. This is the operation used when installing plugins.
  2. { id: target, ... }: find an existing entry by ID, then replace its configuration or disable it.
  3. { id: group, insert: [...] }: insert child entries into a group-type plugin.

Four rules are especially important.

Later layers can modify earlier layers. Patches run in order. An entry inserted by an earlier layer can be modified by ID in a later layer, so order determines precedence. The user layer sits above the bundle layers, which is why it can override a plugin package's default behavior. The one-shot --patch overlays shown in the diagram come after it.

Inserted entries enter the index immediately. This is easy to overlook but essential: a later patch in the same batch can target an entry that was just inserted. Without this behavior, entries inserted by bundles would be black boxes that the user layer could never configure.

A deep-copy snapshot is taken before each layer is applied. Layers do not share objects. Removing a patch can therefore restore the configuration below it cleanly. Rollback is part of the design, rather than an afterthought.

Malformed patches fail loudly; unmatched entries only produce warnings. A broken patch file causes a startup failure instead of letting the process continue with bad configuration. But a patch that does not match an entry only produces a warning and is skipped. One overlay may be shared by several environments, so it does not need to match something everywhere.

I like this distinction: fail as early as possible on invalid configuration, but be permissive about unmatched targets. One is an error; the other can be normal.


The earlier questions finally fit together

Once I understood the mechanism, I could explain the behavior that had confused me.

Why does installing a plugin require a restart? In the normal assembly path, layers are baked into the plugin list at startup. Once the process is running, it does not reread the contents of the bundle layers. Installing a new package or changing its version therefore requires a restart so that the list can be baked again.

Why does the documentation also say “save to apply”? Live reloading watches the two patch files in the user layer, above the bundle layers in the diagram. It uses transactional replay through Cordis HMR: hmr.registerConfig registers the watch; when a file changes, the configuration is reread and the entire layer is replayed. If that fails, the previous generation is retained. Only the user layer is reloaded. The bundle layers underneath remain unchanged. The two descriptions refer to different layers.

Step Install or upgrade a bundle through normal assembly Edit a user patch
Change Install or upgrade the package Edit the profile or home user patch file
Registration Reconcile the layer catalog The watch is registered through hmr.registerConfig
Apply Restart the process and bake the layers again Transactionally replay the changed layer
Result The rebuilt plugin list takes effect after startup Apply the new layer, or retain the previous generation on failure

This comparison concerns normal bundle assembly and user-configuration reloading. Runtime injection through super-injector is a separate path, described below.

Why does running dsh plugin automatically pick up a package installed manually with pnpm add? Reconciliation detects the package's dsh.bundle declaration and appends it to the layer catalog. This is deliberate behavior.

Why does an installed package sometimes produce only a warning and never activate? It has not declared dsh.bundle. Without contributing a layer, it is just an ordinary dependency.

Why does a restart produce duplicate loader entry id? Two layers have inserted the same entry. A typical case is a bundle registering its own entry while the user has also copied that entry into a user patch. Both layers have inserted exactly what they were told to insert.

This mechanism explained six of the seven pitfalls I had encountered. It felt good to have one model that explained the behavior instead of a list of conclusions to memorize.


Two development paths, depending on how often the code changes

With the mechanism clear, I returned to my original ambition: moving from using plugins to developing them. I found two development paths in the DSH ecosystem. The choice depends on how frequently the source changes.

The first is the official assembly path. A plugin is a TypeScript module with export const name and an apply(ctx) function that registers tools, events, and services on the context. For local development, link the package into the profile's node_modules using link:, then attach it with a patch file. Updates follow the installation method: change the version for a registry package, or replace the directory contents for a linked package. One detail matters when replacing a directory: remove the old directory before extracting the new one. Extracting over it can leave behind files that the new package removed, and those stale files may be loaded accidentally.

The second is runtime injection. On my machine, the resident super-injector makes this a hot-swapping workflow: generate a plugin skeleton, build a tgz, inject it into the running process, and hot-reload it without restarting. Failed hot reloads automatically roll back to preserve the old version. Unloading cleans up the fibers, and there is even a dedicated recovery command for orphaned routes left behind by hot reloads.

My choice is to use official assembly for finished plugins that I update with upstream releases, such as archify and super-injector itself. Upgrading takes one command. When I start writing my own plugin and reach the edit-and-try-again stage, I will switch to injection. Restarting after every change would quickly become exhausting.


Closing thoughts

This time, the result was not an impressive end-to-end demo. It was a model: the plugin list is baked, layer order determines precedence, and live configuration reloading happens only in the user layer. When I install another plugin or change configuration, I now have that layered diagram in mind.

As for developing a plugin myself, I have worked out the skeleton and the build process. What I still need is a real requirement to aim at. The next article will start with that plugin: registering the first tool through apply(ctx), declaring dsh.bundle, building a tgz, and injecting it into the runtime. I want to walk the whole path from source code to a plugin running inside DSH and report back whether it succeeds or fails. The memory integration is also moving forward. The rule remains the same: whichever direction produces something first gets the next article.


Environment used for this article: Windows 11 + WSL2, running DSH from a source checkout with pnpm dsh web, using the web profile. Relevant source locations: packages/boot/app-boot for layer loading, and vendor/include for the patch algorithm.

Top comments (0)