dsh-hotreload-plugin-manager
中文 | English
An out-of-tree DeepSeek Harness plugin that gives you hot plugin lifecycle management from the running instance — no restart — through a Web Settings tab:
- 🔥 Hot install — install a plugin bundle by spec (npm / git /
github:owner/repo / file:), hot-applied to the running dsh web immediately, and persisted across restarts.
- 🔥 Hot uninstall — remove a bundle: rows and fibers unmount right away,
the dependency is cleaned up, and stale bundle-layer entries are removed.
- 🔥 Enable / disable — toggle any installed bundle's entries on/off from
the UI; the state is persisted and effective immediately.
- Hot update — swap a bundle's patch rows and re-apply configuration
live (module-code changes still need a restart, per Node's in-process caches).
- Zero core changes — a pure out-of-tree bundle; the dsh checkout stays
untouched.
- Dual-face package — one npm package is both the host gateway and the
browser settings tab.
Architecture
┌────────────────────────────── Browser (React) ──────────────────────────────┐
│ Settings → Plugins → "Plugin Manager" tab (slot settings.plugins.tab) │
│ │ fetch('/plugin-manager/*') │
└────────┼────────────────────────────────────────────────────────────────────┘
▼
┌──────────────────────────── dsh web process ────────────────────────────────┐
│ webserver route (node:http) ──▶ manager core │
│ ① pnpm add/remove (real dependency in the profile) │
│ ② rows written to the profile's user patch layer (cordis.patch.yml) │
│ ③ root Include entry.update → Loader mounts/unmounts fibers │
│ gateway: TypertRemoteService (source-mode reflection, no codegen) │
└─────────────────────────────────────────────────────────────────────────────┘The package is dual-face:
- Node half (
lib/index.js, compiled withtsc) — the
PluginManagerGateway, a TypertRemoteService registered as pluginManager. The api-gateway can dispatch its @Remote methods through source-mode reflection (no generated artifacts), and it registers the browser HTTP transport on the webServer service.
- Browser half (
lib/client.js, bundled with esbuild) — a Web Settings
tab registered into the settings.plugins.tab slot (id manager), discovered by dsh-client-modules and served as a plain module-table bundle (window.__ModuleLoader__.load).
How it works
Everything rides mechanisms the launcher already provides, so the plugin needs no changes to the dsh checkout:
1. Install runs pnpm add <spec> in the profile directory. The package becomes a real dependency (it lands in the profile's node_modules and package.json, so pnpm never prunes it). The manager reads the bundle's dsh.bundle.patch rows and writes them into the profile's user patch layer (cordis.patch.yml). 2. Hot application updates the root Include entry directly (entry.update with the new patch list) — the same transaction the launcher's user-patch watcher uses. The manager applies its own writes itself because the file watcher can miss a write that follows another write closely. 3. Restart persistence is free: the user patch layer recomposes at boot, so installed bundles survive restarts without touching dsh.profile.bundles (and without double-composing rows). 4. Uninstall / update remove or swap the rows and run pnpm remove / pnpm update (registry) or a remove + re-add (file:/link:, whose incremental re-copy does not materialize newly added files). 5. Enable / disable writes a user-layer disable row ({ id, disabled }), which overrides the bundle row by id without duplicating it.
Dependency semantics pass through to pnpm. The manager does not interpret the installed bundle's manifest: optionalDependencies and optional peers behave exactly as pnpm defines them (a failing optional dependency is skipped and the install continues; with the profile's autoInstallPeers: false, peers are never auto-installed and resolve from the dsh installation closure at runtime). The bundle author owns the contract for any non-closure peer the plugin imports.
The browser half talks to the host over a plugin-owned same-origin HTTP transport (/plugin-manager/*) instead of the Typert remote pipeline: the npm dsh-api-gateway package is not installable because several of its dependencies (dsh-type-meta, dsh-compact, …) are unpublished, so an out-of-tree client contribution cannot be mounted. The host gateway still registers a Typert binding for in-process consumers.
Installable plugins (what works)
The one hard requirement: an npm package whose package.json declares "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } (a standard DSH plugin bundle). Everything else about the plugin's internals is free.
Source forms (spec allowlist; pass-through to pnpm add, so anything pnpm resolves works):
- npm package name (optionally with a version):
@kyorakuyk/dsh-plugin-manager,
@scope/name@^1.0.0
- git URLs:
https://github.com/x/y.git,git+ssh://… - GitHub shorthand:
github:owner/repo,owner/repo - Local paths:
file:C:/path/to/bundle,link:…(the common dev/offline
route)
What a bundle may contain (its patch rows are composed by the root Include's interpolation machinery):
- Host plugins: plain Cordis plugins, services, tool registrations
- Browser UI plugins: dual-face
dsh.clientpackages (the browser half is
served through dsh-client-modules)
- Skill providers / MCP adapters: bundles mounting
dsh-skill-filesystem,
dsh-mcp-client
- Patch rows with
insert/ id-targeted overrides / disable rows / groups /
!!js expressions
- Bundles with
optionalDependencies(a failing optional dependency is
skipped and the install continues — verified)
What is rejected (loudly):
- A plain library with no
dsh.bundle.patch→ rejected, and the dependency
is rolled back with pnpm remove (no residue)
- The legacy
.dsh-plugin/config.yamlinstall format → unsupported (the
dsh core removed that path entirely)
- Patch-row id collisions with the user layer or another bundle → rejected
(prevents double composition)
- A git spec whose package name cannot be detected after install → fails loud
with "could not resolve the installed package name"
Boundary notes:
- Non-closure peers: a bundle that imports a peer available neither in
the profile nor in the dsh installation closure fails at runtime — that is the bundle author's contract; the manager does not intervene
- Module-code updates need a restart (Node's in-process caches, see
Known Limitations)
- Do not mix the manager and the
dsh pluginCLI on the same bundle
(uninstall cleans up any CLI-reconciled bundle-layer entry)
Quick start (for other users)
Prerequisites: Node ≥ 22, pnpm ≥ 10, dsh installed with the target profile initialized.
① Install directly from GitHub (recommended) — no need to clone:
dsh plugin --profile web add "github:kyorakuyk/dsh-hotreload-plugin-manager"
# or the full git URL:
# dsh plugin --profile web add "https://github.com/kyorakuyk/dsh-hotreload-plugin-manager.git"② Or clone and install locally:
git clone https://github.com/kyorakuyk/dsh-hotreload-plugin-manager.git
dsh plugin --profile web add "file:$(pwd)/dsh-hotreload-plugin-manager"③ Or install from npm (published):
dsh plugin --profile web add "@kyorakuyk/dsh-plugin-manager"After installing, restart the dsh web instance (a new bundle mounts only on the next boot; the repo ships the built lib/, so no build step is needed), then hard-refresh the browser once (Ctrl+Shift+R):
dsh --profile webThen open the Web UI → Settings → Plugins → Plugin Manager and install a plugin by spec — an npm package name, a git URL, github:owner/repo, or a file:/link: path.
> Published on npm: dsh plugin --profile web add "@kyorakuyk/dsh-plugin-manager".
Web UI
Settings → Plugins → Plugin Manager:
- an install input accepting the spec forms above;
- the installed list with per-bundle enable/disable, update, and
uninstall buttons (a disabled bundle shows "(已禁用)" and the button flips to enable);
- a manual refresh button (the list reflects hot changes without a page
reload);
- operation results inline, including the pnpm output tail on failure.
HTTP API
| Method | Path | Body | Result |
|---|---|---|---|
| GET | /plugin-manager/list | — | { ok, value: Bundle[] } |
| POST | /plugin-manager/install | { spec } | { ok, message, exitCode?, tail? } |
| POST | /plugin-manager/uninstall | { packageName } | same |
| POST | /plugin-manager/update | { packageName } | same |
| POST | /plugin-manager/setEnabled | { entryId, enabled } | same |
Bundle is { packageName, version?, spec, rowIds, disabled }.
Development
pnpm install
pnpm build # tsc (node half) + esbuild (browser bundle)
pnpm test # vitest: 22 tests across REAL-composition, HTTP, and bundle contracts
pnpm typecheckReleasing (npm trusted publishing)
Releases go through GitHub Actions OIDC trusted publishing — no token, no OTP/recovery code needed:
npm version 0.1.5 --no-git-tag-version # bump version
git add package.json pnpm-lock.yaml && git commit -m "pkg: bump to 0.1.5"
git push
git tag v0.1.5 && git push origin v0.1.5 # triggers .github/workflows/publish.ymlThe workflow runs typecheck → build → test → npm publish --provenance (OIDC) → auto-creates a GitHub Release. To re-run a failed publish:
gh workflow run publish.yml --ref main -f publish_ref=refs/tags/v0.1.5> Prerequisite: the npm account must register this repo + publish.yml as a > Trusted Publisher for the package (npmjs.com → package settings → Trusted > Publisher).
Layout:
src/ host half (tsc → lib/*.js)
├── index.ts PluginManagerGateway (TypertRemoteService + @Remote)
├── manager.ts core lifecycle operations
├── http.ts webserver route handler
└── spec|pnpm|patch-layer|state|types.ts
src/client/index.tsx browser half (esbuild → lib/client.js)
scripts/build-client.mjs esbuild config (module-table closure contract)
tests/ vitest suites + fixtures/ test fixturesDependencies are classified so the runtime shares the installation's single Cordis instance: @deepseek-ai/* packages are peerDependencies (resolved from the dsh installation closure), js-yaml is the only runtime dependency, and build/test tooling lives in devDependencies.
Known Limitations and Deferred Work
- Remote-trust: the
/plugin-manager/*route is served to any caller who
can reach the server. The web surface defaults to loopback binding (--host 0.0.0.0 is rejected by the launcher), but a deployment that exposes the server to a LAN must not rely on the route being protected by the /api trust fence; enforcing trusted authorities is deferred.
- CLI mixing: the manager installs bundles as real dependencies but does
not add them to dsh.profile.bundles (rows live in the user patch layer). Running the dsh plugin CLI afterwards reconciles bundle-declaring dependencies into the bundle layer, which double-composes those rows at the next boot. Use one mechanism per bundle (uninstall cleans up any CLI-reconciled bundle-layer entry).
- Specs without a resolvable name: installing a git URL whose package
cannot be detected in the profile's node_modules fails loud with "could not resolve the installed package name".
- Row-id collisions are rejected at install: a bundle whose patch rows
collide with the user layer (or another bundle) is not installed.
- Update hot-applies composition and config, not module code. Node caches
resolved modules and package exports in-process, so a bundle version whose code or exports map changed needs a restart to take effect; the manager's update swaps the patch rows (row set and row config) hot, and the dependency itself is refreshed via pnpm update (registry) or a remove + re-add (file:/link:).
- Enable/disable targets entries by row id; a generated Loader id that is
not a patch row id cannot be toggled.
- pnpm >= 11 supply-chain policies: the default
minimumReleaseAge(24h)
refuses freshly published packages (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION). Every pnpm run the manager performs appends --config.minimumReleaseAge=0 to skip the cooldown (installs are explicit user actions), and it also appends the offender's scope to minimumReleaseAgeExclude in the profile's pnpm-workspace.yaml (e.g. @kyorakuyk/*) as a second line of defense. Git dependencies with lifecycle scripts (prepare etc.) are gated by the allowBuilds allowlist (ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED / ERR_PNPM_IGNORED_BUILDS): the manager extracts the exact spec pnpm demands (name@git+url#sha) from the output, records it in allowBuilds, and retries once. To handle either policy manually, add the scope/spec to the corresponding block in the profile's pnpm-workspace.yaml.
- No plugin marketplace: install is spec-driven; browsing and curated
discovery are deferred.
- Deploying a bundle change to a running profile needs a restart. The
boot manifest's bundle rev and the host half are both frozen at process activation: reinstalling changed code into an already-running profile serves the new client bundle per request while the host still runs the old logic (and the boot graph keeps the old rev). A browser may also hold the old bundle under the old URL. Restart the instance after reinstalling host code, and hard-refresh the browser once; the manager tab tolerates the mismatch window (it guards missing row state).
- The in-box "Plugin inventory" tab is point-in-time. It fetches the
Loader snapshot once on first mount and stays mounted while hidden, so it does not reflect hot lifecycle changes made in the manager tab — reload the page to refresh it. The manager tab itself is live (it re-fetches after every operation and has a manual refresh button).
Model Experience
None: the manager registers no prompt, tool, message, or provider request.
#### KV Cache effect
None; this package never assembles model input.
License
MIT