dsh-hot-reload 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,71 @@
3
3
  All notable changes to `dsh-hot-reload` are documented here. This project
4
4
  follows [semantic versioning](https://semver.org/).
5
5
 
6
+ ## 0.2.2
7
+
8
+ Reloads now re-import a plugin's whole package, not just its entry module.
9
+ Config and API unchanged.
10
+
11
+ **Fixes**
12
+
13
+ - **Multi-file plugins reload as one unit.** The reload used to invalidate only
14
+ the entry URL and re-import it. Under a hoisted linker (`nodeLinker: hoisted`)
15
+ a version bump rewrites the package's files in place, so the entry's relative
16
+ imports (`./routes.js` and friends) resolved to the same URLs and hit the
17
+ stale cache — the running plugin mixed new entry code with old dependency code,
18
+ which for a plugin like `dshmarket` surfaced as a crash looking for a file the
19
+ new version had deleted. The reload now invalidates every cached module under
20
+ the package's own directory (in both the ESM `loadCache` and the CJS
21
+ `require.cache`) before re-importing, so the entry and its in-package imports
22
+ come back together. Shared dependencies live outside that directory and are
23
+ left alone.
24
+
25
+ ## 0.2.1
26
+
27
+ Code-review fixes to the 0.2.0 notification surfaces. No config or API changes,
28
+ and detection and reloading are untouched.
29
+
30
+ **Fixes**
31
+
32
+ - **The pop-ups come back on their own after dsh restarts.** A browser tab whose
33
+ message channel closed for good used to stay silent until you reloaded the
34
+ page. This was easy to hit: the web server opens its socket before this plugin
35
+ adds its route, so a tab retrying in that gap got the web app's HTML instead of
36
+ a message channel, which the browser treats as a permanent failure. The tab now
37
+ opens a new channel after 1s, 2s, 4s, 8s, 16s, then every 30s, up to ten tries
38
+ (about three minutes). If all ten fail it says so in the console and stops, so
39
+ a profile that simply has no host half does not knock at the door forever. Each
40
+ successful connect gives the next outage a fresh set of tries.
41
+ - **A broken `ctx.inject` can no longer stop dsh from starting.** Setting up the
42
+ message channel now runs last and inside a `try`. Before, it ran before the
43
+ file watcher and was the one unguarded call on that path: if a future cordis
44
+ changed `ctx.inject`, `apply()` would throw, dsh's start-up check would refuse
45
+ to boot, and the watcher would never have been created — losing reloading over
46
+ a feature that only reports on it.
47
+ - **A `HEAD` request no longer hangs.** `HEAD` was answered with a never-ending
48
+ message stream, but Node throws away a `HEAD` body, so the caller waited until
49
+ its own timeout while its connection sat in the plugin's list of subscribers
50
+ collecting writes nobody would read. Health checks and link pre-fetchers do
51
+ send `HEAD`. It now gets the headers and a clean close.
52
+ - **"No running copy to swap out" is announced once per version, not forever.**
53
+ That case is deliberately left uncommitted so a plugin that was merely still
54
+ starting gets picked up later — which meant every later lockfile write, even
55
+ for a completely unrelated package, showed the same pop-up again with no way to
56
+ dismiss it. The retry is unchanged; only the repeat announcing is dropped.
57
+
58
+ **Docs**
59
+
60
+ - The README told you to watch for the "no running copy" message *repeating* as
61
+ your sign to restart dsh. It no longer repeats, so that advice was pointing at
62
+ a signal that never comes. Both READMEs now say what to watch instead.
63
+ - **New limitation written down: the lockfile is only a trigger.** Version
64
+ numbers come from each package's installed `package.json`. On pnpm 11 the
65
+ files on disk are written before the lockfile, so what this plugin reads has
66
+ settled — but nothing checks that. A future pnpm that wrote the lockfile first
67
+ could make an upgrade be missed silently. This was a known gap; it was only
68
+ ever recorded in the repo's internal notes, and one of those notes had the
69
+ write order backwards.
70
+
6
71
  ## 0.2.0
7
72
 
8
73
  You can now see reload results without reading the logs. Config did not change:
@@ -30,8 +30,10 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
30
30
  - **已禁用(disabled)的插件行会被静默跳过。** 已禁用的插件本就没在运行,没有
31
31
  可替换的对象;重新启用时 dsh 自然会加载新代码。
32
32
  - **尚未挂上 fiber 的插件**(仍在导入中,或此前加载失败)会被报告为
33
- `no live fiber to reload right now` 并原样保留。由于什么都没有被拆除,这种
34
- 情况**会**在下次 lockfile 变化时重新检查;若反复出现,请重启 dsh。
33
+ `no live fiber to reload right now` 并原样保留。由于什么都没有被拆除,之后
34
+ 每次 lockfile 变化都**会**重新检查它;一旦出现正在运行的副本,它会自己完成
35
+ 重载。提示是**每个版本只给一次**,不是每次检查都给——所以,如果该插件已有
36
+ 足够时间启动,你却仍看到同一个版本被报告,请重启 dsh。
35
37
 
36
38
  它**绝不会替你重启 dsh**——重启交给你(以及你的守护进程,如果有的话)。
37
39
 
@@ -54,6 +56,10 @@ dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
54
56
  - 该插件用 `dsh.hotReload: false` 关闭了热重载
55
57
  - dsh 没有提供重载所需的内部接口
56
58
 
59
+ 其中第二种情况是**每个版本只提示一次**,而不是每次检查都提示。这种情况在此后
60
+ 每次 lockfile 写入时都会重试——包括为别的包发生的写入——所以若不加这个限制,
61
+ 同一条提示会反复出现,而且没有办法关掉它。另外三种情况每次发生都会提示。
62
+
57
63
  提示会滑入,停留数秒,然后淡出。如果一次升级重载了多个插件,提示会排队逐条显示。
58
64
 
59
65
  web 那一部分只在运行 web 服务器的 profile 中加载,并通过
@@ -63,6 +69,10 @@ web 那一部分只在运行 web 服务器的 profile 中加载,并通过
63
69
  提示不会被保存。如果重载发生时没有打开任何浏览器标签页,那条提示就没有了。
64
70
  日志里仍有记录。
65
71
 
72
+ 如果你在标签页开着的时候重启 dsh,标签页会自己重新建立通道,提示继续可用。
73
+ 它大约会尝试三分钟。若仍然连不上,就在浏览器控制台写一行提示并停止重试;
74
+ 刷新页面即可重新开始。
75
+
66
76
  ### 如果你想在终端里看到全部内容
67
77
 
68
78
  上面那一行只覆盖成功的重载。若想看到本插件写进日志的全部内容(包括失败),请把
@@ -111,6 +121,7 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
111
121
  |---|---|
112
122
  | `loader.internal.loadCache` | 使 ESM 模块缓存失效 |
113
123
  | `loader.internal.resolve` / `resolveSync` | 把 specifier 解析为 URL(按 `internal.version` 分派) |
124
+ | `loader.import` / `loader.unwrapExports` | 重新导入新模块,并取出其中的插件导出 |
114
125
  | `registry.plugin` / `registry.delete` | 替换插件实例 |
115
126
  | `fiber.entry`、`fiber.runtime` | 把新插件重新挂到运行中的行上 |
116
127
  | `entry.disabled` | 跳过已禁用的行(继承式 getter) |
@@ -169,6 +180,13 @@ web 应用里的提示(且仅这一部分)还用到:
169
180
  - 重载路径依赖[兼容性](#兼容性)一节列出的 cordis/loader 内部接口。若这些内部
170
181
  不可用(既无 `--expose-internals`,也无 `node-addon-require-builtin` 原生
171
182
  插件),本插件会退化为对每次变化只报告“需要重启”,而不做重载。
183
+ - lockfile 只是**触发器**。版本号是从每个包已安装的 `package.json` 读取的,因为
184
+ 只有这个文件才说明一次 import 实际会拿到什么。在 pnpm 11 上(实测 11.21.0),
185
+ 磁盘上的文件**先**写、lockfile **最后**写,所以本插件动作时读到的版本已经稳定。
186
+ 但插件并不会去*核实*这一点。如果将来某个 pnpm 版本改成先写 lockfile,一次检查
187
+ 就可能读到旧版本、跳过它,而且不再回头看——被监听的只有 lockfile,那次升级就会
188
+ 被**静默**漏掉,不给任何提示,直到你安装另一个版本或重启 dsh。`debounce` 帮不上
189
+ 忙:实测这个间隔有 2.5–4 秒,远超任何合理的 debounce 取值。
172
190
  - 提示通道(`GET /dsh-hot-reload/events`)**不做任何密码校验**,与 dsh 自带的
173
191
  `/plugins/events` 相同。它发送的是插件名和版本号。dsh 的插件列表本来就会显示
174
192
  这些内容,所以并没有多暴露什么秘密。但如果你把 dsh 绑定到 `0.0.0.0`,请把它
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-hot-reload
2
2
 
3
- English | [中文](README.zh.md)
3
+ English | [中文](README-zh.md)
4
4
 
5
5
  Live-reload upgraded [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugins **without restarting dsh**.
6
6
 
@@ -36,8 +36,10 @@ Two cases produce no reload, by design:
36
36
  new code anyway.
37
37
  - **A plugin that has no live fiber *yet*** (still importing, or it failed to
38
38
  load earlier) is reported as `no live fiber to reload right now` and left
39
- alone. Nothing was torn down, so this one *is* re-examined on the next
40
- lockfile change if it keeps repeating, restart dsh.
39
+ alone. Nothing was torn down, so this one *is* re-examined on every later
40
+ lockfile change, and it reloads by itself as soon as a running copy appears.
41
+ You are told **once per version**, not once per check — so if you still see
42
+ the same version reported after the plugin has had time to start, restart dsh.
41
43
 
42
44
  It **never restarts dsh for you** — restarting is left to you (and your
43
45
  supervisor, if any).
@@ -64,6 +66,11 @@ old code is still running:
64
66
  - the plugin turned off hot reload with `dsh.hotReload: false`
65
67
  - dsh did not provide the internal parts the reload needs
66
68
 
69
+ The second case is announced **once per version**, not once per check. That case
70
+ is retried on every later lockfile write — including writes for other packages —
71
+ so without this limit you would get the same pop-up over and over, with no way
72
+ to dismiss it. The other three are announced each time they happen.
73
+
67
74
  The message slides in, stays a few seconds, then fades out. If one upgrade
68
75
  reloads several plugins, the messages line up and show one after another.
69
76
 
@@ -74,6 +81,11 @@ as `tui`, still gets the terminal line and the log.
74
81
  Messages are not saved. If no browser tab is open when a reload happens, that
75
82
  message is gone. The log still has the record.
76
83
 
84
+ If you restart dsh with a tab open, the tab opens a new channel by itself and
85
+ pop-ups keep working. It tries for about three minutes. If it still cannot
86
+ connect after that, it writes one line to the browser console and stops trying;
87
+ reload the page to start again.
88
+
77
89
  ### If you want every line in your terminal
78
90
 
79
91
  The terminal line above only covers reloads that worked. To see everything this
@@ -125,6 +137,7 @@ future dsh that changes any of them may require an update:
125
137
  |---|---|
126
138
  | `loader.internal.loadCache` | invalidating the ESM module cache |
127
139
  | `loader.internal.resolve` / `resolveSync` | resolving a specifier to a URL (dispatched on `internal.version`) |
140
+ | `loader.import` / `loader.unwrapExports` | re-importing the fresh module and unwrapping its plugin export |
128
141
  | `registry.plugin` / `registry.delete` | swapping the plugin instance |
129
142
  | `fiber.entry`, `fiber.runtime` | re-attaching the new plugin to the running rows |
130
143
  | `entry.disabled` | skipping disabled rows (inherited getter) |
@@ -192,6 +205,16 @@ live fiber to swap). It does **not** detect *silent* leaks:
192
205
  [Compatibility](#compatibility). If they are unavailable (no
193
206
  `--expose-internals` and no `node-addon-require-builtin` addon), the plugin
194
207
  degrades to reporting "restart needed" for every change instead of reloading.
208
+ - The lockfile is only the **trigger**. Version numbers are read from each
209
+ package's installed `package.json`, because that is the only file that says
210
+ what an import would really get. On pnpm 11 (measured on 11.21.0) the files on
211
+ disk are written **first** and the lockfile **last**, so by the time this
212
+ plugin acts, the versions it reads have settled. But nothing here *checks*
213
+ that. If some future pnpm wrote the lockfile first, a check could read the old
214
+ version, skip it, and never look again — the lockfile is the only thing
215
+ watched, so that upgrade would be missed **silently**, with no message, until
216
+ you install another version or restart dsh. The `debounce` setting does not
217
+ help: the gap measured 2.5–4 seconds, far longer than any sane debounce.
195
218
  - The message channel (`GET /dsh-hot-reload/events`) has **no password check**,
196
219
  the same as dsh's own `/plugins/events`. It sends plugin names and version
197
220
  numbers. dsh already shows those through its plugin list, so this adds no new
package/lib/client.js CHANGED
@@ -52,6 +52,14 @@ window.__ModuleLoader__.load({
52
52
  * single-occupancy — registering there would shadow the entire app frame.) */
53
53
  const SLOT = "shell.overlay";
54
54
 
55
+ /** Waits before each attempt to re-open a channel that closed for good, in
56
+ * ms. The list is the whole retry policy: its values are the schedule and
57
+ * its LENGTH is the budget — run off the end and the tab gives up, so a
58
+ * host half that is simply not there costs ten requests, not a request
59
+ * every few seconds forever. 16s of fast retries covers a dsh restart;
60
+ * the 30s tail covers a slow one. */
61
+ const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000];
62
+
55
63
  /** Cordis plugin name. */
56
64
  const name = "dsh-hot-reload";
57
65
  /** Required services: the slot registry this half contributes its banner to. */
@@ -88,32 +96,70 @@ window.__ModuleLoader__.load({
88
96
 
89
97
  React.useEffect(() => {
90
98
  let seq = 0;
91
- const source = new EventSource(EVENTS_ENDPOINT);
92
- source.addEventListener("message", (event) => {
93
- let frame;
94
- try {
95
- frame = JSON.parse(event.data);
96
- } catch {
97
- return;
98
- }
99
- if (frame === null || typeof frame !== "object") return;
100
- if (frame.type !== "notice" || typeof frame.text !== "string") return;
101
- seq += 1;
102
- setQueue((q) => q.concat({ seq, kind: frame.kind, text: frame.text }));
103
- });
104
- // A dead channel is otherwise invisible: with no route registered, the
105
- // request falls through to the SPA fallback and answers 200 text/html,
106
- // which EventSource rejects as a permanent failure rather than retrying.
107
- // Say so once, so "the feature is off" is distinguishable from "broken".
108
- let reported = false;
109
- source.addEventListener("error", () => {
110
- if (reported || source.readyState !== 2 /* CLOSED */) return;
111
- reported = true;
112
- warn(`dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} is unavailable — no reload banners`);
113
- });
114
- // Otherwise EventSource reconnects on its own; the host holds no per-tab
115
- // state, so a reconnect costs nothing and misses only what it was down for.
116
- return () => source.close();
99
+ let source = null;
100
+ let timer = null;
101
+ let retries = 0;
102
+ let stopped = false;
103
+
104
+ const connect = () => {
105
+ const es = new EventSource(EVENTS_ENDPOINT);
106
+ source = es;
107
+ // A successful connect gives the next outage a full retry budget
108
+ // again, so the bound below is per outage rather than per tab. It
109
+ // still bounds a host half that is simply absent: with nothing ever
110
+ // answering there is no open event, so the budget never refills.
111
+ es.addEventListener("open", () => {
112
+ retries = 0;
113
+ });
114
+ es.addEventListener("message", (event) => {
115
+ let frame;
116
+ try {
117
+ frame = JSON.parse(event.data);
118
+ } catch {
119
+ return;
120
+ }
121
+ if (frame === null || typeof frame !== "object") return;
122
+ if (frame.type !== "notice" || typeof frame.text !== "string") return;
123
+ seq += 1;
124
+ setQueue((q) => q.concat({ seq, kind: frame.kind, text: frame.text }));
125
+ });
126
+ es.addEventListener("error", () => {
127
+ // A transient drop leaves readyState CONNECTING and EventSource
128
+ // retries it on its own — nothing to do. CLOSED is the fatal case
129
+ // and the one that needs us: with no route registered the request
130
+ // falls through to the SPA fallback and answers 200 text/html,
131
+ // which EventSource treats as a permanent failure. That happens on
132
+ // every dsh restart, because the web server binds its socket before
133
+ // this plugin's route is registered — so without re-arming here, one
134
+ // ill-timed retry would cost the banner for the life of the tab.
135
+ if (stopped || es.readyState !== 2 /* CLOSED */) return;
136
+ es.close();
137
+ const delay = RETRY_DELAYS_MS[retries];
138
+ if (delay === undefined) {
139
+ warn(
140
+ `dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} still unavailable after ` +
141
+ `${RETRY_DELAYS_MS.length} retries — no reload banners until this page is reloaded`
142
+ );
143
+ return;
144
+ }
145
+ // Say it once per outage, so "the feature is off" stays
146
+ // distinguishable from "broken", without a warning per retry.
147
+ if (retries === 0) {
148
+ warn(`dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} is unavailable — retrying`);
149
+ }
150
+ retries += 1;
151
+ timer = setTimeout(connect, delay);
152
+ });
153
+ };
154
+
155
+ connect();
156
+ // The host holds no per-tab state, so a reconnect costs nothing and
157
+ // misses only the notices raised while it was down.
158
+ return () => {
159
+ stopped = true;
160
+ if (timer !== null) clearTimeout(timer);
161
+ if (source !== null) source.close();
162
+ };
117
163
  }, []);
118
164
 
119
165
  const head = queue[0];
package/lib/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // dsh-hot-reload — live-reload upgraded dsh plugins without restarting dsh.
2
2
  //
3
3
  // It watches the profile's pnpm-lock.yaml; when an already-loaded plugin
4
- // package's version changes, it invalidates that module's caches, re-imports
5
- // the new code, and swaps the running plugin fiber in place — the same
4
+ // package's version changes, it invalidates that package's cached modules
5
+ // (entry plus in-package imports), re-imports the new code, and swaps the
6
+ // running plugin fiber in place — the same
6
7
  // technique cordis-plugin-hmr uses, but reaching into node_modules (which HMR
7
8
  // deliberately ignores).
8
9
  //
@@ -29,8 +30,8 @@
29
30
  import { watch } from "chokidar";
30
31
  import { readFileSync, existsSync } from "node:fs";
31
32
  import { createRequire } from "node:module";
32
- import { fileURLToPath } from "node:url";
33
- import { join } from "node:path";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
+ import { dirname, join } from "node:path";
34
35
 
35
36
  export const name = "dsh-hot-reload";
36
37
 
@@ -131,65 +132,6 @@ export function apply(ctx, config = {}) {
131
132
  }
132
133
  }
133
134
 
134
- // ctx.inject, NOT a module-level `export const inject`, and NOT a one-shot
135
- // ctx.get. The distinction matters three ways:
136
- //
137
- // - a module-level inject is REQUIRED (Inject.resolve maps every declared
138
- // name to a wait), so it would park the whole plugin forever in a profile
139
- // that has no web server — tui would stop reloading anything at all;
140
- // - ctx.inject parks only this CHILD fiber, leaving the reloader running;
141
- // - ctx.get would be both racy and one-shot. It resolves strictly, returning
142
- // undefined unless the providing fiber is already ACTIVE, and WebServer
143
- // only becomes active after its async listen() binds — while loader entries
144
- // start concurrently, so whether we win that race is chance. Being a single
145
- // read, it also never recovers: a web server that reloads (port change, a
146
- // dsh HMR cycle) comes back with an empty route table and nothing would
147
- // re-register. ctx.inject re-runs this body on exactly that event.
148
- ctx.inject(["webServer"], (webCtx) => {
149
- // Acquire and release in one effect, as dsh's own client-hmr channel does:
150
- // the disposer drops the route and every open stream when this child fiber
151
- // unloads — on shutdown, and before the body re-runs for a replaced server.
152
- webCtx.effect(() => {
153
- let disposeRoute;
154
- try {
155
- disposeRoute = webCtx.webServer.register({
156
- kind: "exact",
157
- path: EVENTS_ENDPOINT,
158
- handler: (req, res) => {
159
- if (req.method !== "GET" && req.method !== "HEAD") {
160
- res.writeHead(405);
161
- res.end();
162
- return;
163
- }
164
- res.writeHead(200, {
165
- "content-type": "text/event-stream",
166
- "cache-control": "no-cache",
167
- connection: "keep-alive",
168
- });
169
- res.write(": connected\n\n");
170
- connections.add(res);
171
- res.on("close", () => connections.delete(res));
172
- },
173
- });
174
- } catch (err) {
175
- // Duplicate path (a second dsh-hot-reload row) or a webserver API change.
176
- // The notices are optional; the reloader is not — degrade, never throw.
177
- log.warn?.("dsh-hot-reload: could not register the notice channel; web notices are disabled");
178
- log.warn?.(err);
179
- return () => {};
180
- }
181
- return () => {
182
- disposeRoute();
183
- for (const res of connections) {
184
- try {
185
- res.destroy();
186
- } catch {}
187
- }
188
- connections.clear();
189
- };
190
- }, "dsh-hot-reload: notice channel");
191
- });
192
-
193
135
  // ---- package <-> loader-entry helpers ----
194
136
 
195
137
  /** Package name backing a loader entry's module specifier, or null for local/builtin. */
@@ -205,11 +147,20 @@ export function apply(ctx, config = {}) {
205
147
  }
206
148
 
207
149
  // Versions come from node_modules/<pkg>/package.json, never from the lockfile
208
- // we watch: pnpm writes the lockfile BEFORE materializing node_modules, so a
209
- // cycle triggered by that write can see the new version there while the old
210
- // code is still on disk re-importing would load the OLD module while
211
- // committing the NEW version as loaded. The lockfile is the trigger; the
212
- // installed package.json is the truth about what an import would actually get.
150
+ // we watch. The lockfile records what pnpm RESOLVED; only the installed
151
+ // package.json describes what an import would actually get, and nothing ties
152
+ // the write we observe to the state of the tree on disk.
153
+ //
154
+ // Measured on pnpm 11.21.0 (`add`, `update` and `install`, four runs): the
155
+ // tree is materialized FIRST and the lockfile written LAST, 20-40ms after the
156
+ // final package lands — so a cycle triggered by that write reads settled
157
+ // versions. The design does not DEPEND on that order, but it is exposed to
158
+ // it: a pnpm that wrote the lockfile first would have a cycle read stale
159
+ // versions, `continue` past them, and never re-arm, because nothing but the
160
+ // lockfile is watched — the upgrade would be lost silently and permanently.
161
+ // The debounce is no defense; the materialization window measured 2.5-4s.
162
+ // A settle/confirm pass would close it. Not implemented; see README
163
+ // "Limitations", which states the exposure plainly.
213
164
  function readPkgJson(pkg) {
214
165
  try {
215
166
  return JSON.parse(readFileSync(join(nodeModules, pkg, "package.json"), "utf8"));
@@ -322,6 +273,49 @@ export function apply(ctx, config = {}) {
322
273
  } catch {}
323
274
  }
324
275
 
276
+ /** Directory, as a trailing-slash file:// URL, of the nearest ancestor that
277
+ * has a package.json — the package this module belongs to. A version bump
278
+ * rewrites every file inside it in place under a hoisted linker, so a reload
279
+ * must drop the whole directory, not just the entry. Returns null when no
280
+ * package.json is found (defensive; a node_modules package always has one). */
281
+ function packageRootUrlOf(url) {
282
+ let dir = dirname(fileURLToPath(url));
283
+ for (;;) {
284
+ if (existsSync(join(dir, "package.json"))) {
285
+ const href = pathToFileURL(dir).href;
286
+ return href.endsWith("/") ? href : `${href}/`;
287
+ }
288
+ const parent = dirname(dir);
289
+ if (parent === dir) return null;
290
+ dir = parent;
291
+ }
292
+ }
293
+
294
+ /** Invalidate every cached module under one package directory — the entry and
295
+ * its transitive in-package imports. Re-importing only the entry would leave
296
+ * `./routes.js` and friends on their stale cache entries (the URLs are
297
+ * unchanged under a hoisted linker), so the running plugin would mix old and
298
+ * new code. Shared dependencies live OUTSIDE this directory and are left
299
+ * alone: re-importing them would be wasteful and would fork shared classes. */
300
+ function invalidateTree(rootUrl) {
301
+ if (!rootUrl) return;
302
+ // ESM loadCache.
303
+ try {
304
+ for (const u of Map.prototype.keys.call(internal.loadCache)) {
305
+ if (typeof u === "string" && u.startsWith(rootUrl)) {
306
+ Map.prototype.delete.call(internal.loadCache, u);
307
+ }
308
+ }
309
+ } catch {}
310
+ // CJS: modules imported via import() also land in the require cache on Node 24.
311
+ try {
312
+ const rootPath = fileURLToPath(rootUrl);
313
+ for (const fp of Object.keys(cjsRequire.cache)) {
314
+ if (fp.startsWith(rootPath)) delete cjsRequire.cache[fp];
315
+ }
316
+ } catch {}
317
+ }
318
+
325
319
  /** Reload one loaded entry's module in place. Throws on failure, after rolling
326
320
  * the old plugin back — except when teardown began mid-reload, where it drops
327
321
  * the new plugin and does NOT roll back (the dying context disposes what is
@@ -340,7 +334,14 @@ export function apply(ctx, config = {}) {
340
334
  const url = await resolveUrl(specifier, parentURL);
341
335
  if (!url) throw new Error(`could not resolve ${specifier}`);
342
336
 
343
- invalidate(url); // matters for in-place edits; harmless no-op for a version bump (new realpath)
337
+ // Drop the whole package directory, not just the entry: under a hoisted
338
+ // linker a version bump rewrites files in place (same URLs), so the entry's
339
+ // relative imports would otherwise re-hit the stale cache and mix old and
340
+ // new code. Falls back to the single-URL invalidation only when no
341
+ // package.json is found.
342
+ const rootUrl = packageRootUrlOf(url);
343
+ if (rootUrl) invalidateTree(rootUrl);
344
+ else invalidate(url);
344
345
 
345
346
  // Read the version as close to the import as possible: this is what the
346
347
  // fresh module actually is, and the only value safe to commit.
@@ -417,13 +418,23 @@ export function apply(ctx, config = {}) {
417
418
 
418
419
  if (!live.length) {
419
420
  if (!fiberless) return version; // only disabled rows — nothing to do, nothing to say
420
- // Enabled but nothing attached: mid-import or a load failure. Say so once,
421
+ // Enabled but nothing attached: mid-import or a load failure. Say so,
421
422
  // don't commit, and stay retryable — no reload was attempted, so a plugin
422
423
  // that was merely still activating picks this up on a later event.
423
- report(
424
- "stale",
425
- `${pkg}@${version} has no live fiber to reload right now restart dsh if it stays on the old version`
426
- );
424
+ //
425
+ // Announce it at most once per version, though. Staying retryable means
426
+ // the version is deliberately NOT committed, so every later lockfile
427
+ // write — including ones for entirely unrelated packages — re-detects the
428
+ // same change and would re-announce it. Tolerable as a log line; as an
429
+ // undismissable banner it would follow the user through routine `pnpm
430
+ // add` work forever. Only the announcing is suppressed; the retry is not.
431
+ if (noticedVersions[pkg] !== version) {
432
+ noticedVersions[pkg] = version;
433
+ report(
434
+ "stale",
435
+ `${pkg}@${version} has no live fiber to reload right now — restart dsh if it stays on the old version`
436
+ );
437
+ }
427
438
  return false;
428
439
  }
429
440
  if (fiberless) {
@@ -470,6 +481,7 @@ export function apply(ctx, config = {}) {
470
481
  // silently (old code keeps running, no notice). Deliberate, not an oversight.
471
482
  let versions = boot ? currentVersions(boot) : null;
472
483
  const failedVersions = Object.create(null); // pkg -> version whose reload failed (never retried)
484
+ const noticedVersions = Object.create(null); // pkg -> version already announced on a retryable path
473
485
  let timer = null;
474
486
  let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
475
487
  let disposed = false;
@@ -513,6 +525,7 @@ export function apply(ctx, config = {}) {
513
525
  } else if (commit) {
514
526
  versions[pkg] = commit; // the version actually imported, not the cycle-start one
515
527
  delete failedVersions[pkg];
528
+ delete noticedVersions[pkg];
516
529
  }
517
530
  // commit === false: not attempted (teardown, or nothing attached yet) —
518
531
  // leave it uncommitted and retryable on a later event.
@@ -525,6 +538,7 @@ export function apply(ctx, config = {}) {
525
538
  if (!(pkg in snap)) {
526
539
  delete versions[pkg];
527
540
  delete failedVersions[pkg];
541
+ delete noticedVersions[pkg];
528
542
  }
529
543
  }
530
544
  }
@@ -564,6 +578,89 @@ export function apply(ctx, config = {}) {
564
578
  } catch {}
565
579
  });
566
580
 
581
+ // ---- notice channel ----
582
+ //
583
+ // LAST on purpose, and wrapped. Everything above is the reloader; everything
584
+ // here is an optional extra surface for talking about it. Registered earlier,
585
+ // any throw from this block would abort apply() before the watcher exists —
586
+ // so a change in a cordis API this plugin only uses for NOTICES would cost the
587
+ // reloading too, and dsh's boot sweep turns a FAILED entry into a refusal to
588
+ // start at all. Ordered and guarded, the worst case is "no banners".
589
+ //
590
+ // ctx.inject, NOT a module-level `export const inject`, and NOT a one-shot
591
+ // ctx.get. The distinction matters three ways:
592
+ //
593
+ // - a module-level inject is REQUIRED (Inject.resolve maps every declared
594
+ // name to a wait), so it would park the whole plugin forever in a profile
595
+ // that has no web server — tui would stop reloading anything at all;
596
+ // - ctx.inject parks only this CHILD fiber, leaving the reloader running;
597
+ // - ctx.get would be both racy and one-shot. It resolves strictly, returning
598
+ // undefined unless the providing fiber is already ACTIVE, and WebServer
599
+ // only becomes active after its async listen() binds — while loader entries
600
+ // start concurrently, so whether we win that race is chance. Being a single
601
+ // read, it also never recovers: a web server that reloads (port change, a
602
+ // dsh HMR cycle) comes back with an empty route table and nothing would
603
+ // re-register. ctx.inject re-runs this body on exactly that event.
604
+ try {
605
+ ctx.inject(["webServer"], (webCtx) => {
606
+ // Acquire and release in one effect, as dsh's own client-hmr channel does:
607
+ // the disposer drops the route and every open stream when this child fiber
608
+ // unloads — on shutdown, and before the body re-runs for a replaced server.
609
+ webCtx.effect(() => {
610
+ let disposeRoute;
611
+ try {
612
+ disposeRoute = webCtx.webServer.register({
613
+ kind: "exact",
614
+ path: EVENTS_ENDPOINT,
615
+ handler: (req, res) => {
616
+ if (req.method === "HEAD") {
617
+ // Node discards a HEAD response body, so the usual stream would
618
+ // never reach the caller: it would block until its own timeout
619
+ // while its socket sat in `connections` collecting writes nobody
620
+ // reads. Health checks and link prefetchers do send HEAD.
621
+ // Answer what the headers would be, then close.
622
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
623
+ res.end();
624
+ return;
625
+ }
626
+ if (req.method !== "GET") {
627
+ res.writeHead(405);
628
+ res.end();
629
+ return;
630
+ }
631
+ res.writeHead(200, {
632
+ "content-type": "text/event-stream",
633
+ "cache-control": "no-cache",
634
+ connection: "keep-alive",
635
+ });
636
+ res.write(": connected\n\n");
637
+ connections.add(res);
638
+ res.on("close", () => connections.delete(res));
639
+ },
640
+ });
641
+ } catch (err) {
642
+ // Duplicate path (a second dsh-hot-reload row) or a webserver API change.
643
+ // The notices are optional; the reloader is not — degrade, never throw.
644
+ log.warn?.("dsh-hot-reload: could not register the notice channel; web notices are disabled");
645
+ log.warn?.(err);
646
+ return () => {};
647
+ }
648
+ return () => {
649
+ disposeRoute();
650
+ for (const res of connections) {
651
+ try {
652
+ res.destroy();
653
+ } catch {}
654
+ }
655
+ connections.clear();
656
+ };
657
+ }, "dsh-hot-reload: notice channel");
658
+ });
659
+ } catch (err) {
660
+ log.warn?.("dsh-hot-reload: could not set up the notice channel; web notices are disabled");
661
+ log.warn?.(err);
662
+ }
663
+
567
664
  log.info?.(
568
665
  `dsh-hot-reload: watching ${lockfile} (${Object.keys(versions ?? {}).length} plugin package(s) tracked)`
569
666
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hot-reload",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh \u2014 the running plugin is swapped in place, and a reload that fails rolls back to the working old version and asks for a manual restart.",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "lib",
37
37
  "cordis.patch.yml",
38
38
  "README.md",
39
- "README.zh.md",
39
+ "README-zh.md",
40
40
  "CHANGELOG.md",
41
41
  "LICENSE"
42
42
  ],