# dsh-hotreload-plugin-manager [δΈ­ζ–‡](README.md) | English An out-of-tree [DeepSeek Harness](https://github.com/deepseek-ai/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 with `tsc`) β€” 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 ` 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.client` packages (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.yaml` install 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 plugin` CLI** 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: ```sh 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**: ```sh 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): ```sh 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`): ```sh dsh --profile web ``` Then 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 ```sh pnpm install pnpm build # tsc (node half) + esbuild (browser bundle) pnpm test # vitest: 22 tests across REAL-composition, HTTP, and bundle contracts pnpm typecheck ``` ### Releasing (npm trusted publishing) Releases go through GitHub Actions OIDC trusted publishing β€” **no token, no OTP/recovery code needed**: ```sh 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.yml ``` The workflow runs typecheck β†’ build β†’ test β†’ `npm publish --provenance` (OIDC) β†’ auto-creates a GitHub Release. To re-run a failed publish: ```sh 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 fixtures ``` Dependencies 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