stuarthu/dsh-hot-reload
Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh — safe reloads in place, failed ones roll back and flag a restart.
已收录
0
Dev
Bundle 已验证
功能介绍
插件升级后在运行中的 dsh 内直接重载,无需重启;重载失败时回滚到旧版本并提示手动重启。
适合
- 频繁升级正在运行的 DSH 插件、希望缩短反馈周期的插件开发者。
- 不希望每次升级已安装插件都重启整个进程的长时间 DSH 会话。
- 希望重载失败时保留可工作的旧版本,并收到需要手动重启提示的运维人员。
不适合
- 安装全新插件的场景,因为它只处理已加载插件的版本变更。
- 已禁用、尚无活动 fiber,或明确设置 dsh.hotReload 为 false 的插件。
- 要求升级必然即时生效的环境;重载可能退化为只能重启,未来锁文件写入顺序变化也可能导致漏检升级。
README
dsh-hot-reload
| English | 中文 |
Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh.
dsh’s built-in hot-reload (cordis-plugin-hmr) deliberately ignores
node_modules, so upgrading an installed plugin (dsh plugin add pkg@x)
normally requires a full dsh restart to take effect. This plugin closes that
gap: it watches your profile’s pnpm-lock.yaml, and when an already-loaded
plugin package’s version changes, it swaps the running plugin in place.
Behavior
On a plugin package upgrade, for each affected plugin:
- Reload it live — invalidate the module caches, re-import the new code, and re-instantiate the plugin fiber in place. dsh, your sessions, and every other plugin keep running.
-
If the reload fails — you’re left with the working old version, never a
dead plugin, plus a logged note that a manual
dshrestart is needed to pick up the new code. Two cases, both handled:- a failure while loading the new code (bad import, syntax error) is caught before the running plugin is touched — the old plugin is never disturbed;
- a failure while initializing it (the new
applythrows, sync or async) is rolled back — the old version is re-instantiated in place.
A version that failed is not retried automatically — retrying would tear down the working plugin again on every later lockfile write. Install a different version, or restart dsh, to pick the new code up.
Two cases produce no reload, by design:
- Disabled plugin rows are skipped silently. A disabled plugin isn’t running, so there is nothing to swap — and re-enabling it makes dsh load the new code anyway.
-
A plugin that has no live fiber yet (still importing, or it failed to
load earlier) is reported as
no live fiber to reload right nowand left alone. Nothing was torn down, so this one is re-examined on every later lockfile change, and it reloads by itself as soon as a running copy appears. You are told once per version, not once per check — so if you still see the same version reported after the plugin has had time to start, restart dsh.
It never restarts dsh for you — restarting is left to you (and your supervisor, if any).
How you see what happened
The plugin writes every result to dsh’s log. But dsh does not print its log to your terminal, so those lines are easy to miss. Two extra places show you what happened.
1. One line in your terminal, for every outcome. You get this in every profile:
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
2. A short pop-up message in the dsh web app. You get one when a reload works. You also get one in every case where the new code did not load, so the old code is still running:
- the reload failed, and the old version was put back
- the plugin has no running copy to swap out
- the plugin turned off hot reload with
dsh.hotReload: false - dsh did not provide the internal parts the reload needs
The second case is announced once per version, not once per check. That case is retried on every later lockfile write — including writes for other packages — so without this limit you would get the same pop-up over and over, with no way to dismiss it. The other three are announced each time they happen.
The message slides in, stays a few seconds, then fades out. If one upgrade reloads several plugins, the messages line up and show one after another.
The web part only loads in a profile that runs a web server. It sends the
messages over GET /dsh-hot-reload/events. A profile with no web server, such
as tui, still gets the terminal line and the log.
Messages are not saved. If no browser tab is open when a reload happens, that message is gone. The log still has the record.
If you restart dsh with a tab open, the tab opens a new channel by itself and pop-ups keep working. It tries for about three minutes. If it still cannot connect after that, it writes one line to the browser console and stops trying; reload the page to start again.
If you want every line in your terminal
The terminal line above covers every reload outcome — reloaded, failed, and stale (not attempted — the old code is still running). To see everything else this plugin writes to the log (its warnings and diagnostics), add dsh’s console logger to your profile. It is a separate package:
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
Then add a row for it in that profile’s cordis.patch.yml and restart dsh:
- insert:
- id: logger-console
name: '@deepseek-ai/cordis-plugin-logger-console'
This prints all dsh log lines, not only this plugin’s.
Note for full-screen profiles. The terminal line is written straight to the screen. In a profile that draws a full-screen interface, such as
tui, the line can land in the middle of the drawing and make the screen look wrong. It looks wrong only until the screen is drawn again.
Install
dsh plugin --profile web add dsh-hot-reload
Then restart dsh once (bundle patch layers load at boot). After that, upgrades apply live:
dsh plugin --profile web add some-plugin@newer # reloaded automatically
Works in any profile — swap web for whichever profile you use; it watches
the profile it’s loaded into.
Compatibility
Built and tested against dsh 0.1.0-rc.6 (Node 22 / 24). It reaches into
cordis/loader internals — mostly the same ones cordis-plugin-hmr uses — so a
future dsh that changes any of them may require an update:
| Internal | Used for |
|---|---|
loader.internal.loadCache |
invalidating the ESM module cache |
loader.internal.resolve / resolveSync
|
resolving a specifier to a URL (dispatched on internal.version) |
loader.import / loader.unwrapExports
|
re-importing the fresh module and unwrapping its plugin export |
registry.plugin / registry.delete
|
swapping the plugin instance |
fiber.entry, fiber.runtime
|
re-attaching the new plugin to the running rows |
entry.disabled |
skipping disabled rows (inherited getter) |
entry.options.group |
skipping group container rows |
The pop-up message in the web app (and only that part) also uses:
| dsh part | Used for |
|---|---|
ctx.webServer.register |
serving the message channel |
window.__ModuleLoader__ |
loading the browser half |
the shell.overlay slot |
placing the message over the app |
Toast from @deepseek-ai/dsh-client-ui-primitives
|
drawing it |
The plugin fails safe. If a part it needs is missing, it reports “restart needed”
instead of breaking dsh. The pop-up behaves the same way. A missing web server,
a browser module it cannot load, an unknown slot, a repeated registration, or a
dsh build with no Toast each cost you the pop-up only. Reloading still works,
and the web app still starts.
One exception: the browser half asks dsh for a service named slots. dsh’s web
app refuses to start if any plugin never becomes ready. So if some future dsh
build had no slots service at all, this part would wait forever and show up in
dsh’s start-up error list. Every other failure listed above is caught and simply
does nothing.
Opting out
A plugin that knows it isn’t safe to hot-reload can force the restart-needed
path (no reload attempt) by declaring, in its own package.json:
{ "dsh": { "hotReload": false } }
Configuration
Set on the hot-reload row in your profile’s cordis.patch.yml:
| Key | Default | Meaning |
|---|---|---|
debounce |
300 |
ms to wait after a lockfile change before acting |
profileDir |
auto | absolute path to the profile dir to watch (auto-detected from the loader base URL if omitted) |
Limitations — read this
This plugin is optimistic, not verified. It attempts the reload and falls back to “restart needed” only when something throws (or when there is no live fiber to swap). It does not detect silent leaks:
- A plugin that acquires a raw resource outside cordis — a bare
setInterval, anet/httpserver, aWebSocketServer, anfs.watch, achild_process— without actx.effectdisposer can reload without throwing yet leave that resource dangling (a stray timer, a duplicate listener, an orphaned watcher). These accumulate across upgrades and are cleared only by an eventual restart. - Cordis auto-unwinds everything a plugin registers through
ctx(ctx.effect,ctx.on,ctx.provide, tool schemas, adapters), so well-behaved plugins reload cleanly. The risk is limited to plugins that bypassctx. If in doubt, have such a plugin setdsh.hotReload: false. - Reloading a plugin that holds live connections (e.g. a WebSocket bridge) drops and re-establishes them; clients must reconnect. That’s expected, not an error.
- The reload path relies on the cordis/loader internals listed under
Compatibility. If they are unavailable (no
--expose-internalsand nonode-addon-require-builtinaddon), the plugin degrades to reporting “restart needed” for every change instead of reloading. - The lockfile is only the trigger. Version numbers are read from each
package’s installed
package.json, because that is the only file that says what an import would really get. On pnpm 11 (measured on 11.21.0) the files on disk are written first and the lockfile last, so by the time this plugin acts, the versions it reads have settled. But nothing here checks that. If some future pnpm wrote the lockfile first, a check could read the old version, skip it, and never look again — the lockfile is the only thing watched, so that upgrade would be missed silently, with no message, until you install another version or restart dsh. Thedebouncesetting does not help: the gap measured 2.5–4 seconds, far longer than any sane debounce. -
The message channel (
GET /dsh-hot-reload/events) has no password check, the same as dsh’s own/plugins/events. It sends plugin names and version numbers. dsh already shows those through its plugin list, so this adds no new secret. But if you bind dsh to0.0.0.0, count it as one more address that anyone on your network can open. - A plugin that loads after
dsh-hot-reloadin the bundle order is reloaded once — to its current version — on the first lockfile write after boot, even when that write was for an unrelated package. Until this plugin has tracked the package across one cycle, it cannot tell whether the running code is the old or the current version, so it reloads rather than adopting a version that may never have run. For an HMR-safe plugin this is a harmless single redundant reload.
Scope note: this handles upgrades of already-loaded plugins. Installing a
brand-new plugin is a separate concern (adding its row to cordis.patch.yml,
which dsh already hot-applies).
License
MIT © Stuart Hu
常见问题常见问题
在启用了 DSH 的终端中执行已验证命令 dsh plugin --profile default add github:stuarthu/dsh-hot-reload。命令会解析公开 package 元数据,并保持插件与本页展示的目录身份一致。
兼容性以页面上展示的 bundle 与 profile 状态为准。如果某个 profile 尚未检测到,请先保持禁用,并在生产启用前阅读仓库文档。
GitHub 链接和 activity 元数据是 release 与维护状态的来源。新版本发布后重新查看本页,确认目录已经观察到最新版本。