From 6fe4a2e184e06bf5229e4d5671a6c5121a6007ae Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 1 Oct 2026 01:40:36 +0800
Subject: [PATCH 1/9] fix(hmr): invalidate changed package manifests
---
...efresh-and-manifest-invalidation.i18n.yaml | 31 ++
...ckage-refresh-and-manifest-invalidation.md | 67 ++++
...ge-refresh-and-manifest-invalidation.zh.md | 67 ++++
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/event-producer-consumer.md | 4 +-
docs/event-producer-consumer.zh.md | 4 +-
packages/boot/hmr/README.i18n.yaml | 8 +-
packages/boot/hmr/README.md | 4 +-
packages/boot/hmr/README.zh.md | 4 +-
packages/boot/hmr/package.json | 1 +
packages/boot/hmr/src/index.ts | 10 +-
packages/boot/hmr/src/package-manifest.ts | 315 ++++++++++++++++++
.../tests/package-manifest-dispatch.spec.ts | 118 +++++++
.../boot/hmr/tests/package-manifest.spec.ts | 284 ++++++++++++++++
pnpm-lock.yaml | 3 +
16 files changed, 909 insertions(+), 15 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
create mode 100644 .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md
create mode 100644 packages/boot/hmr/src/package-manifest.ts
create mode 100644 packages/boot/hmr/tests/package-manifest-dispatch.spec.ts
create mode 100644 packages/boot/hmr/tests/package-manifest.spec.ts
diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml
new file mode 100644
index 0000000000..4e41e40c4e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml
@@ -0,0 +1,31 @@
+# Bilingual-pair consistency record for 2026-09-30-profile-package-refresh-and-manifest-invalidation.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
+/agent-note-expire-hmr-package-configuration:
+ en: e6905d7657387304
+ zh: a7e906173a82f27c
+/agent-note-expire-hmr-package-configuration/problem:
+ en: 0c94c90beb8f27da
+ zh: 721bec27065b1c52
+/agent-note-expire-hmr-package-configuration/decision:
+ en: a15c7c13f167e5ac
+ zh: a15c7c13f167e5ac
+/agent-note-expire-hmr-package-configuration/decision/responsibilities:
+ en: 8e2c32e7bd3603fd
+ zh: 702ce5a1c5a45ccd
+/agent-note-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
+ en: d0becbacae5038cc
+ zh: 8d20bce7ccc84152
+/agent-note-expire-hmr-package-configuration/decision/unsupported-scenarios:
+ en: 0386bb3af5765b44
+ zh: 3206e2d2286b8c9c
+/agent-note-expire-hmr-package-configuration/alternatives-considered:
+ en: de12dc08fc9c52bb
+ zh: 3385a252091626a3
+/agent-note-expire-hmr-package-configuration/verification:
+ en: 31671100a78f896e
+ zh: db0c6976c2615257
+/agent-note-expire-hmr-package-configuration/consequences:
+ en: 16dd88467b324ab2
+ zh: 0f59b55846e27ca5
diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
new file mode 100644
index 0000000000..255f6bac4f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
@@ -0,0 +1,67 @@
+# Agent Note: Expire HMR package configuration
+
+Status: implemented
+
+English | [中文](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)
+
+## Problem
+
+- **HMR handled module contents but not package.json.** `packages/boot/hmr` clears module caches on a source change and re-imports the loaded URL. A plugin directory's package.json is not a module: HMR ignored its change and kept Node's package-configuration caches (the C++ package.json reader cache, `package_json_reader`'s nearest-manifest cache, the ESM `ResolveCache`, and CommonJS `_pathCache`). Later reloads and imports kept the old exports, main, and imports, and format detection kept the old type.
+
+## Decision
+
+### Responsibilities
+
+| Owner | Does | Does not |
+|---|---|---|
+| HMR (`packages/boot/hmr`) | Expires a package's configuration caches when a package.json inside its watched roots changes | Reload plugins for a package.json change; handle `node_modules` |
+
+### HMR package-configuration expiry
+
+In HMR's change dispatch, a changed file named `package.json` outside `node_modules` goes to `PackageManifests.invalidate()` instead of `partialReload`. Source changes in the same batch reload afterwards and read the new configuration.
+
+`invalidate(manifest)` records the directory as expired. Afterwards:
+
+- package.json reads, scope lookups, type lookups, and nearest-manifest lookups below that directory read the current file. Ownership follows the real directory when a consumer reaches the package through a link.
+- ESM `ResolveCache` entries whose parent lies in that directory or whose result points into it are deleted.
+- CommonJS `_pathCache` entries whose result or lookup path lies in that directory are deleted.
+
+Loaded modules stay unchanged. A reloaded plugin is re-imported from its loaded URL, so an entry rename takes effect when the Loader entry restarts, for example after disabling and enabling it, when the Loader resolves the package name again.
+
+The implementation lives in `packages/boot/hmr/src/package-manifest.ts`, which encapsulates every Node internal interface it needs; `index.ts` adds only the dispatch branch, a field, and restoration on disposal. HMR's `node_modules` exclusion is unchanged.
+
+| Interface | Action |
+|---|---|
+| modules binding `readPackageJSON`, `getPackageScopeConfig`, `getPackageType` | Replaced: paths in an expired directory read the current file; other calls reach the native method |
+| `package_json_reader.getNearestParentPackageJSON` | Replaced: expired directories bypass its JS cache; a lookup without a manifest returns the native absent result |
+| The ESM Loader's `ResolveCache` instance | The prototype `get` is replaced for one lookup to obtain the instance and restored at once; entries are then deleted by parent |
+| CommonJS `Module._pathCache` | Entries below an expired directory are deleted |
+
+The binding's `getNearestParentPackageJSON` is called only by `package_json_reader` and is not replaced. The replacements are installed at the first expiry and restored when the HMR service is disposed.
+
+### Unsupported scenarios
+
+Overwriting or upgrading a package installed in `node_modules`, and reinstalling at the same path after removal, still require restart. Module caches, `node_modules` package-configuration caches, and HMR's `node_modules` exclusion are unchanged.
+
+## Alternatives considered
+
+**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart.
+
+**Reload plugins when package.json changes.** package.json is not a module, and changing it does not necessarily call for re-evaluation; source HMR is driven by source changes. After expiry, the next source reload or new import reads the new configuration.
+
+**Re-import reloaded plugins by package name.** It would let an entry rename follow a source reload, but changes `partialReload`'s rule that the loaded URL is the reload unit. Entry renames already take effect when the Loader entry restarts, so this is not changed.
+
+## Verification
+
+| Coverage | Location |
+|---|---|
+| Expired exports, main, imports, type, scope, and nearest manifests; parity with the native reader; the `node_modules` boundary; restoration | `packages/boot/hmr/tests/package-manifest.spec.ts` |
+| HMR dispatch: a manifest change alone reloads nothing, a same-batch source reload reads the new imports, an entry restart reads the new entry, and `node_modules` manifests are ignored | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
+
+Tests need no API key and make no model calls.
+
+## Consequences
+
+- During HMR development, changes to package.json imports, exports, main, and type reach the next reload or new import; package.json alone triggers no reload.
+- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 22, 24, and 26.
+- The implementation depends on several Node internal interfaces, and these tests must run again for Node upgrades.
diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md
new file mode 100644
index 0000000000..07b25ec623
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md
@@ -0,0 +1,67 @@
+# Agent Note: HMR 包配置失效
+
+Status: implemented
+
+[English](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) | 中文
+
+## Problem
+
+- **HMR 只处理模块内容,不处理 package.json。** `packages/boot/hmr` 在源码变化时清模块缓存,并按原 URL 重新 import。插件目录里的 package.json 不是模块,改了它,HMR 既不处理这个事件,也不清 Node 的包配置缓存(C++ 的 package.json 读取缓存、`package_json_reader` 的最近 package.json 缓存、ESM `ResolveCache`、CJS `_pathCache`)。之后的源码重载和新导入仍按旧的 exports、main、imports 解析,按旧的 type 判断格式。
+
+## Decision
+
+### 职责
+
+| 负责方 | 做什么 | 不做什么 |
+|---|---|---|
+| HMR(`packages/boot/hmr`) | 监听范围内 package.json 变化时,让该包的配置缓存失效 | 不因 package.json 变化重载插件;不处理 `node_modules` |
+
+### HMR 包配置失效
+
+HMR 的文件变化分派中,文件名为 `package.json` 且不在 `node_modules` 内的变化,交给 `PackageManifests.invalidate()`,不进 `partialReload`。同一批里的源码变化随后照常重载,重载后的模块读到新配置。
+
+`invalidate(manifest)` 把该目录登记为已失效,之后:
+
+- 位于该目录下的 package.json 读取、scope 查询、type 查询、最近 package.json 查询,都按当前磁盘解析。消费方经由链接路径访问时,按真实目录判断归属。
+- ESM `ResolveCache` 中,parent 位于该目录、或结果指向该目录的条目被删除。
+- CJS `_pathCache` 中,结果或查找路径位于该目录的条目被删除。
+
+已加载模块保持不变。重载的插件按已加载的 URL 重新 import,因此入口文件改名在 Loader entry 重新启动(例如停用后再启用)时生效,那时 Loader 按包名重新解析。
+
+实现位于 `packages/boot/hmr/src/package-manifest.ts`,所需的 Node internal 接口都封装在这个文件里;`index.ts` 只增加分派、字段和卸载时的恢复。`node_modules` 的排除规则不变。
+
+| 接口 | 动作 |
+|---|---|
+| modules binding 的 `readPackageJSON`、`getPackageScopeConfig`、`getPackageType` | 替换:路径位于已失效目录时按当前磁盘解析,其余调用原生方法 |
+| `package_json_reader.getNearestParentPackageJSON` | 替换:已失效目录绕过它的 JS 缓存;没有 manifest 时返回原生的缺失结果 |
+| ESM Loader 的 `ResolveCache` 实例 | 临时替换原型的 `get`,取得实例后立即恢复,再按 parent 删除条目 |
+| CJS `Module._pathCache` | 删除落在已失效目录下的条目 |
+
+binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调用,不替换。这些替换在第一次失效时安装,HMR 服务卸载时恢复。
+
+### 不支持的场景
+
+覆盖或升级 `node_modules` 里已安装的包、卸载后在同一路径重装,仍然需要重启。模块缓存、`node_modules` 的包配置缓存和 HMR 的 `node_modules` 排除规则都不变。
+
+## Alternatives considered
+
+**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
+
+**package.json 变化时重载插件。** package.json 不是模块,改它不一定意味着需要重新求值;源码 HMR 本来就由源码变化驱动。失效之后,下一次源码重载或新导入自然会读到新配置。
+
+**按插件名重新 import 被重载的插件。** 这能让入口改名随源码重载生效,但会改变 `partialReload` 以已加载 URL 为重载单位的规则。入口改名在 Loader entry 重启时已经生效,所以本次不改。
+
+## Verification
+
+| 覆盖面 | 位置 |
+|---|---|
+| 失效后的 exports、main、imports、type、scope、最近 package.json,与原生读取的一致性,node_modules 边界,恢复 | `packages/boot/hmr/tests/package-manifest.spec.ts` |
+| HMR 分派:仅 manifest 变化不重载、同批源码重载读新 imports、entry 重启读新入口、node_modules 内不处理 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
+
+测试不需要 API key,不调用模型。
+
+## Consequences
+
+- HMR 开发流程中改 package.json 的 imports、exports、main、type,下一次重载或新导入就会读到;package.json 本身不触发重载。
+- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。
+- 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index bcee8614ed..3db279af54 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1305,7 +1305,7 @@ export interface Config {
## `@deepseek-ai/dsh-hmr`
- `refs`: `ChokidarOptions` (`chokidar`)
-- `source`: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
+- `source`: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
```ts config-catalog
/** Module roots and watcher timing, with Chokidar deployment options. */
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 1f734eac99..3477f6d6fd 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1307,7 +1307,7 @@ export interface Config {
## `@deepseek-ai/dsh-hmr`
- `refs`: `ChokidarOptions` (`chokidar`)
-- `source`: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
+- `source`: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
```ts config-catalog
/** Module roots and watcher timing, with Chokidar deployment options. */
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 3ed51c618a..fd447cfde0 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -51,8 +51,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 911434f569..43581d4e83 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -53,8 +53,8 @@
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml
index e66a76d589..c6534e2bf7 100644
--- a/packages/boot/hmr/README.i18n.yaml
+++ b/packages/boot/hmr/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 45ee194ad781d603
zh: b3d7fcd8ed73717c
/deepseek-ai-dsh-hmr/understand-the-implementation:
- en: 247499831fdee81f
- zh: 690f1859c1f73bbe
+ en: 9b2724d4cedcd1a0
+ zh: 863bf05e3b115c05
/deepseek-ai-dsh-hmr/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -39,8 +39,8 @@
en: 89e0624fafd701fb
zh: 19d36a33c1d9af9d
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work:
- en: 379bc2c72fd9e53e
- zh: 1fff1e0e676bb161
+ en: dcb1f29ee2e973d4
+ zh: 520bc58f2e7de42e
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note:
en: 7972e4b2005ec9a4
zh: 754c49ec538c164c
diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md
index 6c7d9bb87f..1d9c32b717 100644
--- a/packages/boot/hmr/README.md
+++ b/packages/boot/hmr/README.md
@@ -56,7 +56,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
-App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
+App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. A changed `package.json` outside `node_modules` is not reloaded as a module: HMR expires Node's cached package configuration for that package directory, including package reads, scope, type, and nearest-manifest lookups, ESM `ResolveCache`, and CommonJS `_pathCache` entries, and reloads nothing for it. Later resolutions, including the imports of a plugin reloaded for a source change in the same or a later batch, read the current manifest; a reloaded plugin is re-imported from its loaded URL, so a new entry takes effect when its Loader entry restarts. [package-manifest.ts](src/package-manifest.ts) owns the Node internal interfaces this uses. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
@@ -86,7 +86,7 @@ Reloading a contributing plugin can change later request prefixes; HMR does not
- Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process.
-- Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism.
+- Replacing installed package versions still requires a restart through Plugin Manager; manifests below `node_modules` keep Node's cached configuration. The browser Client module graph retains its separate browser-side loading mechanism.
- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected.
### Dev Note
diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md
index b6976cf255..4972cffc72 100644
--- a/packages/boot/hmr/README.zh.md
+++ b/packages/boot/hmr/README.zh.md
@@ -56,7 +56,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。
-App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
+App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。`node_modules` 之外的 `package.json` 变化不作为模块重载:HMR 让该包目录在 Node 中缓存的包配置失效,包括 package 读取、scope、type 与最近 manifest 查询、ESM `ResolveCache` 和 CommonJS `_pathCache` 条目,但不因此重载任何插件。之后的解析,包括同一批或之后因源码变化重载的插件的 import,都读取当前 manifest;重载的插件按已加载的 URL 重新 import,因此新入口在其 Loader 条目重启时生效。[package-manifest.ts](src/package-manifest.ts) 封装这里用到的 Node internal 接口。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
@@ -86,7 +86,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
-- 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。
+- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。
- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。
### 开发备注
diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json
index 5e61624165..d274564c0d 100644
--- a/packages/boot/hmr/package.json
+++ b/packages/boot/hmr/package.json
@@ -38,6 +38,7 @@
"dependencies": {
"@babel/code-frame": "^7.29.0",
"chokidar": "^4.0.3",
+ "node-addon-require-builtin": "^0.1.6",
"picomatch": "^4.0.3",
"@deepseek-ai/schemastery": "workspace:~"
},
diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts
index 045890f022..6965aa0e40 100644
--- a/packages/boot/hmr/src/index.ts
+++ b/packages/boot/hmr/src/index.ts
@@ -5,11 +5,12 @@ import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
import type { Include } from '@deepseek-ai/cordis-plugin-include'
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
-import { basename, dirname, join, relative, resolve } from 'node:path'
+import { basename, dirname, join, relative, resolve, sep } from 'node:path'
import { readFileSync, realpathSync } from 'node:fs'
import { readProfileManifest, readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-cmdline'
import { handleError } from './error.ts'
+import { PackageManifests } from './package-manifest.ts'
import type {} from '@deepseek-ai/cordis-plugin-timer'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
@@ -131,6 +132,7 @@ class Hmr extends Service {
private applicationReady: Promise = Promise.resolve(true)
private closing = false
private readonly configPaths = new Set()
+ private readonly manifests = new PackageManifests()
/** Serialize a caller-owned mutation with all automatic reload paths.
* @param operation Work that must not overlap module or configuration replacement.
@@ -201,6 +203,7 @@ class Hmr extends Service {
await this.watcher?.close()
// A configuration reload may remove its own HMR entry.
if (!this.executing.getStore()) await this.operations
+ this.manifests.dispose()
}
const profile = this.ownerContext.get('profileContext')
@@ -276,6 +279,11 @@ class Hmr extends Service {
const filename = canonicalPath(resolve(watchBaseDir, path))
const configuredFilename = resolve(this.baseDir, path)
if (this.configPaths.has(filename) || this.configPaths.has(configuredFilename)) continue
+ // Package configuration is not a module: later resolutions read it again, but nothing reloads for it.
+ if (basename(filename) === 'package.json' && !filename.includes(`${sep}node_modules${sep}`)) {
+ this.manifests.invalidate(filename)
+ continue
+ }
const url = pathToFileURL(filename).href
if (this.externals.has(url)) {
fullReload = true
diff --git a/packages/boot/hmr/src/package-manifest.ts b/packages/boot/hmr/src/package-manifest.ts
new file mode 100644
index 0000000000..c6d0258156
--- /dev/null
+++ b/packages/boot/hmr/src/package-manifest.ts
@@ -0,0 +1,315 @@
+/** Expire Node's cached package configuration for package directories whose manifest changed. */
+import { isUtf8 } from 'node:buffer'
+import { readFileSync, realpathSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { basename, dirname, join, sep, toNamespacedPath } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+/** Package fields Node's resolver consumes; exports and imports hold JSON values. */
+interface PackageConfig {
+ name?: string
+ main?: string
+ type: string
+ exports?: unknown
+ imports?: unknown
+}
+
+/** Serialized fields returned by the native modules binding: name, main, type, imports, exports, manifest path. */
+type PackageTuple = [string | undefined, string | undefined, string, string | undefined, string | undefined, string]
+
+/**
+ * Methods of `internalBinding('modules')` that read package configuration through Node's native cache. Node's
+ * package reader, CommonJS loader, and ESM resolver and format detection look them up at each call.
+ */
+interface PackageBinding {
+ readPackageJSON(this: void, path: string, isEsm?: boolean, base?: string, specifier?: string): PackageTuple | undefined
+ getPackageScopeConfig(this: void, url: string): PackageTuple | string
+ getPackageType(this: void, url: string): string | undefined
+}
+
+/**
+ * `internal/modules/package_json_reader` export that keeps its own nearest-manifest cache; the CommonJS loader calls it
+ * through the module object, so it is the only caller of the binding's nearest-manifest lookup.
+ */
+interface PackageReader {
+ getNearestParentPackageJSON(this: void, path: string): { data: PackageConfig; exists: boolean; path: string } | undefined
+}
+
+/** Node's ESM resolution cache: a Map from parent URL to the serialized request keys resolved from it. */
+interface ResolveCacheClass {
+ prototype: { get: (this: Map, key: string, parent: string) => unknown }
+}
+
+interface CommonJsModule {
+ _pathCache: Record
+}
+
+interface NativeAccess {
+ binding: PackageBinding
+ reader: PackageReader
+ ResolveCache: ResolveCacheClass
+ cjs: CommonJsModule
+ esmLoader: { resolveSync(...args: unknown[]): unknown }
+}
+
+/** Snapshot own methods; each disposer restores its method only while its replacement is still installed. */
+function createReplaceHelper unknown }>(target: T) {
+ const rawImpl = { ...target }
+ return {
+ rawImpl,
+ replaceMethod(key: K, replacement: T[K]): () => void {
+ target[key] = replacement
+ return () => { if (target[key] === replacement) target[key] = rawImpl[key] }
+ },
+ }
+}
+
+/** Same lookup as the vendored Loader's `requireInternal`: `--expose-internals` first, then the builtin addon. */
+function requireInternal(id: string): unknown {
+ const require = createRequire(import.meta.url)
+ if (process.execArgv.includes('--expose-internals')) {
+ try {
+ return require(id)
+ } catch (_error) {
+ // Some internals, such as internal/bootstrap/realm, stay private under --expose-internals.
+ }
+ }
+ try {
+ return (require('node-addon-require-builtin') as { requireBuiltin(id: string): unknown }).requireBuiltin(id)
+ } catch (_error) {
+ // Without the addon, the caller reports the missing interface.
+ }
+}
+
+function loadNodeInternals(): NativeAccess {
+ const realm = requireInternal('internal/bootstrap/realm') as { internalBinding(id: 'modules'): PackageBinding }
+ const esm = requireInternal('internal/modules/esm/loader') as {
+ getOrInitializeCascadedLoader(): NativeAccess['esmLoader']
+ }
+ return {
+ binding: realm.internalBinding('modules'),
+ reader: requireInternal('internal/modules/package_json_reader') as PackageReader,
+ ResolveCache: (requireInternal('internal/modules/esm/module_map') as { ResolveCache: ResolveCacheClass }).ResolveCache,
+ cjs: (requireInternal('internal/modules/cjs/loader') as { Module: CommonJsModule }).Module,
+ esmLoader: esm.getOrInitializeCascadedLoader(),
+ }
+}
+
+/**
+ * Read the ESM loader's resolution cache by observing one lookup through its prototype.
+ * The prototype method is restored before this returns.
+ */
+function captureEsmResolveCache(native: NativeAccess, parentURL: string): Map | undefined {
+ const prototype = native.ResolveCache.prototype
+ const original = prototype.get
+ const seen = new Set