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..161c3259a7
--- /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: e327f239592ffec0
+ zh: 339aa414b9dc8652
+/agent-note-expire-hmr-package-configuration/decision:
+ en: a15c7c13f167e5ac
+ zh: a15c7c13f167e5ac
+/agent-note-expire-hmr-package-configuration/decision/responsibilities:
+ en: e72abbf8fafc89bb
+ zh: f6c59177696c5cd3
+/agent-note-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
+ en: c7de8dafe47a5587
+ zh: 6010a70d8451edd1
+/agent-note-expire-hmr-package-configuration/decision/future-work:
+ en: 31818f8f7f80470b
+ zh: ecb7ac85bb49cf6d
+/agent-note-expire-hmr-package-configuration/alternatives-considered:
+ en: 5dab1237a312169c
+ zh: b24927d5badedcf7
+/agent-note-expire-hmr-package-configuration/verification:
+ en: d1d931904beb1c9f
+ zh: ea7874b974e1c209
+/agent-note-expire-hmr-package-configuration/consequences:
+ en: e557562e3f7b07bc
+ zh: 3cd196a63e4099b0
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..f14533b350
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
@@ -0,0 +1,78 @@
+# Agent Note: Expire HMR package configuration
+
+Status: implemented
+
+English | [中文](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)
+
+## Problem
+
+- **Package configuration and module evaluation have separate caches.** Clearing a plugin's module cache does not refresh the package.json fields that Node uses for exports, main, imports, and format detection. A package.json can also be imported as a JSON module, in which case its consumers need ordinary module reloads.
+
+## Decision
+
+### Responsibilities
+
+| Owner | Does | Does not |
+|---|---|---|
+| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement |
+
+### HMR package-configuration expiry
+
+In HMR's change dispatch, a changed file named `package.json` outside `node_modules` goes to `PackageManifests.invalidate()`. A manifest in the host dependency graph still requests a host reload; one loaded as a JSON module still reloads its consumers. A configuration-only manifest schedules no module reload. Configuration-owned paths stay with their dedicated watcher. Source changes in the same batch reload afterwards.
+
+`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` is cleared. Its entries do not record every consulted manifest, and a package entry may resolve outside the package directory.
+- CommonJS `_pathCache` is cleared for the same reason. Unrelated requests recompute their resolution without unloading their modules.
+- CommonJS loads using the default resolver pass the freshly resolved filename to the native loader. Its private request alias cannot select an older entry; cached module instances remain intact.
+
+Configuration invalidation alone leaves loaded modules unchanged. Loader entries retain their raw import results before export normalization. HMR matches imported Node module objects to cached ModuleJob namespaces and reloads the loaded URLs. All matching entry records, including disabled entries, update only after a successful reload and remain unchanged on failure. Stopping and restarting a Loader entry resolves its package name again and selects the new entry.
+
+Entry names are scoped by configuration-tree base URL and retain all distinct imported namespaces. An uninitialized entry contributes the name without erasing loaded namespaces; name-based resolution is used only when none is recorded, or for `cordis:` builtins. Dependency analysis considers every recorded module. Entries sharing a plugin runtime use one replacement operation, while each Loader entry selects its replacement by its original namespace. Module imports and export normalization finish before the old runtime is removed. Failed imports or activation restore the previous modules and plugin implementations and clean up partially activated replacements.
+
+An instance without a Loader entry has no recorded module identity. If a shared runtime's replacement modules have different plugin callbacks, HMR reports an ambiguity error and rolls back instead of assigning that instance to an arbitrary module.
+
+Package invalidation lives in `packages/boot/hmr/src/package-manifest.ts`, which encapsulates its Node internal interfaces. `index.ts` dispatches manifest changes and locates loaded entries by module identity. 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; the resolution cache is then cleared |
+| CommonJS `Module._pathCache` | The request-to-filename cache is cleared |
+| CommonJS `Module._load` | Default-resolver requests load by resolved filename; builtins and registered resolve hooks keep the original path |
+
+The binding's `getNearestParentPackageJSON` is called only by `package_json_reader` and is not replaced. Each HMR instance owns a separate configuration cache. Its replacements are installed at the first expiry and restored when the service is disposed, alongside watcher and reload-queue cleanup.
+
+### Future Work
+
+- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly.
+- TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization.
+- CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior.
+- Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred.
+
+## 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 every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads.
+
+**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.
+
+**Keep one namespace per entry name.** Entries with the same name and base URL can have loaded different modules before and after a manifest change. Keeping only the first or last namespace loses a live module; an uninitialized entry must not erase another entry's imported namespace.
+
+## Verification
+
+| Coverage | Location |
+|---|---|
+| Expired exports, main, imports, type, scope, and nearest manifests; native-reader parity; actual CommonJS loads and retained module instances; the `node_modules` boundary; restoration | `packages/boot/hmr/tests/package-manifest.spec.ts` |
+| Configuration-only manifests, JSON-module and host reloads, source reloads, same-name and shared-runtime entries, import and activation rollback, entry restarts, and the `node_modules` exclusion | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
+
+Tests need no API key and make no model calls.
+
+## Consequences
+
+- In the supported thread, package.json configuration reads use the changed fields; JSON modules retain their own reload behavior. The separate module, loader-thread, and Worker limitations above still apply.
+- 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..87e57686d0
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md
@@ -0,0 +1,78 @@
+# Agent Note: HMR 包配置失效
+
+Status: implemented
+
+[English](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) | 中文
+
+## Problem
+
+- **包配置与模块求值使用独立缓存。** 清理插件的模块缓存不会刷新 Node 用于 exports、main、imports 和格式判断的 package.json 字段。package.json 也可以被作为 JSON 模块导入,此时其消费方需要普通的模块重载。
+
+## Decision
+
+### 职责
+
+| 负责方 | 做什么 | 不做什么 |
+|---|---|---|
+| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 |
+
+### HMR 包配置失效
+
+HMR 的文件变化分派中,文件名为 `package.json` 且不在 `node_modules` 内的变化,交给 `PackageManifests.invalidate()`。位于宿主依赖图中的 manifest 仍请求宿主重载;作为 JSON 模块加载的 manifest 仍重载消费方。仅作为配置的 manifest 不安排模块重载。配置专属路径仍由其独立 watcher 处理。同一批的源码变化随后重载。
+
+`invalidate(manifest)` 把该目录登记为已失效,之后:
+
+- 位于该目录下的 package.json 读取、scope 查询、type 查询、最近 package.json 查询,都按当前磁盘解析。消费方经由链接路径访问时,按真实目录判断归属。
+- 清空 ESM `ResolveCache`。其条目不记录查询过的全部 manifest,包入口也可能解析到包目录之外。
+- 基于相同原因清空 CommonJS `_pathCache`。无关请求重新计算解析结果,不卸载其模块。
+- 使用默认 resolver 的 CommonJS 加载把重新解析的文件名交给原生 loader。其私有请求别名不再选中旧入口,已缓存的模块实例保持不变。
+
+仅使配置失效不会改变已加载模块。Loader entry 保留导出规范化之前的原始导入结果。HMR 将已导入的 Node 模块对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,重载已加载 URL。所有匹配的 entry 记录,包括已停用的 entry,仅在重载成功后更新,失败时保持不变。停止并重新启动 Loader entry 时,会重新按包名解析并选择新入口。
+
+entry 名称以配置树 base URL 为作用域,并保留所有不同的已导入命名空间。尚未初始化的 entry 只登记名称,不抹去已加载的命名空间;仅在没有记录命名空间或入口为 `cordis:` 内置模块时按名称解析。依赖分析考虑每个已记录模块。共享插件运行时的 entry 使用同一次替换操作,但每个 Loader entry 按自身原命名空间选择替换实现。模块导入与导出规范化在移除旧运行时之前完成。导入或激活失败时,恢复原来的模块和插件实现,并清理已部分激活的替换实例。
+
+没有 Loader entry 的实例不具备已记录的模块身份。如果共享运行时的替换模块具有不同的插件回调,HMR 会报告歧义错误并回滚,而不是将该实例随意分配给某个模块。
+
+包配置失效实现在 `packages/boot/hmr/src/package-manifest.ts`,封装其使用的 Node internal 接口。`index.ts` 分派 manifest 变更,并按模块身份定位已加载 entry。HMR 的 `node_modules` 排除规则不变。
+
+| 接口 | 动作 |
+|---|---|
+| modules binding 的 `readPackageJSON`、`getPackageScopeConfig`、`getPackageType` | 替换:路径位于已失效目录时按当前磁盘解析,其余调用原生方法 |
+| `package_json_reader.getNearestParentPackageJSON` | 替换:已失效目录绕过它的 JS 缓存;没有 manifest 时返回原生的缺失结果 |
+| ESM Loader 的 `ResolveCache` 实例 | 临时替换原型的 `get`,取得实例后立即恢复,再清空解析缓存 |
+| CJS `Module._pathCache` | 清空请求到文件名的缓存 |
+| CJS `Module._load` | 默认 resolver 的请求按解析后的文件名加载;内置模块及注册的 resolve hooks 保留原路径 |
+
+binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调用,不替换。每个 HMR 实例拥有独立的配置缓存,在第一次失效时安装 hook,并在服务销毁时恢复,同时清理 watcher 和重载队列。
+
+### 后续工作
+
+- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。
+- 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。
+- 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。
+- 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。
+
+## Alternatives considered
+
+**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
+
+**package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。
+
+**按插件名重新 import 被重载的插件。** 这能让入口改名随源码重载生效,但会改变 `partialReload` 以已加载 URL 为重载单位的规则。入口改名在 Loader entry 重启时已经生效,所以本次不改。
+
+**每个 entry 名称只保留一个命名空间。** 名称和 base URL 相同的 entry,可能在 manifest 变化前后加载了不同模块。只保留最早或最后一个命名空间会遗漏仍在使用的模块;尚未初始化的 entry 也不能抹去其他 entry 的已导入命名空间。
+
+## Verification
+
+| 覆盖面 | 位置 |
+|---|---|
+| 失效后的 exports、main、imports、type、scope、最近 package.json,原生 reader 对照,实际 CommonJS 加载及旧模块实例保留,node_modules 边界,恢复 | `packages/boot/hmr/tests/package-manifest.spec.ts` |
+| 配置专用 manifest、JSON 模块与宿主重载、源码重载、同名及共享运行时的 entry、导入与激活失败回滚、entry 重启和 node_modules 排除规则 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
+
+测试不需要 API key,不调用模型。
+
+## Consequences
+
+- 在支持的线程内,package.json 配置读取使用更新后的字段;JSON 模块保留自己的重载行为。上述模块、loader 线程及 Worker 的独立限制仍然适用。
+- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。
+- 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。
diff --git a/apps/desktop-host/src/office-engine.ts b/apps/desktop-host/src/office-engine.ts
index d8f0bd1d5f..f58019d116 100644
--- a/apps/desktop-host/src/office-engine.ts
+++ b/apps/desktop-host/src/office-engine.ts
@@ -29,7 +29,11 @@ export function installOfficeEngineResolution(runtimeDir: string): ModuleHooks |
return registerHooks({
resolve(specifier, context, nextResolve) {
const resolved = nextResolve(specifier, context)
- if (!/^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)) return resolved
+ if (!resolved.url.startsWith('file:')) return resolved
+ const engineRequest = /^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)
+ const engineTarget = /\/node_modules\/@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-[^/]+\//u
+ .test(new URL(resolved.url).pathname)
+ if (!engineRequest && !engineTarget) return resolved
const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href
if (!canonical.startsWith(source)) {
if (canonical.startsWith(pathToFileURL(archive + '/').href)) {
diff --git a/apps/desktop-host/tests/office-engine.spec.ts b/apps/desktop-host/tests/office-engine.spec.ts
index 716aef756d..83c544dc01 100644
--- a/apps/desktop-host/tests/office-engine.spec.ts
+++ b/apps/desktop-host/tests/office-engine.spec.ts
@@ -1,19 +1,26 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { createRequire, type ModuleHooks } from 'node:module'
import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { dirname, join, sep } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { installOfficeEngineResolution } from '../src/office-engine.ts'
+import { PackageManifests } from '../../../packages/boot/hmr/src/package-manifest.ts'
const roots: string[] = []
const hooks: ModuleHooks[] = []
+const disposers: Array<() => void> = []
afterEach(() => {
+ for (const dispose of disposers.splice(0).reverse()) dispose()
for (const hook of hooks.splice(0)) hook.deregister()
+ const cache = createRequire(import.meta.url).cache
+ for (const path of Object.keys(cache)) {
+ if (roots.some(root => path.startsWith(root + sep))) Reflect.deleteProperty(cache, path)
+ }
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(runtimeName = 'dsh') {
- const root = mkdtempSync(join(tmpdir(), 'desktop-office-resolution-'))
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-resolution-')))
roots.push(root)
const runtime = join(root, 'app.asar', runtimeName)
const manifest = 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json'
@@ -40,6 +47,45 @@ it('resolves engine manifests to physical directories and leaves unrelated modul
expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' })
})
+it('resolves an absolute engine manifest to its unpacked copy', () => {
+ const f = fixture()
+ expect(f.require(join(f.runtime, f.manifest)))
+ .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) })
+})
+
+it.each(['exports', 'main'] as const)('refreshes a cached plugin %s while Office resolution stays active', (field) => {
+ const f = fixture()
+ const packageRoot = join(f.root, 'plugin')
+ mkdirSync(packageRoot)
+ const manifest = join(packageRoot, 'package.json')
+ const a = join(packageRoot, 'a.cjs')
+ const b = join(packageRoot, 'b.cjs')
+ writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './a.cjs' }))
+ writeFileSync(a, 'module.exports = { marker: "a" }')
+ writeFileSync(b, 'module.exports = { marker: "b" }')
+ symlinkSync(packageRoot, join(f.runtime, 'node_modules', 'plugin'), process.platform === 'win32' ? 'junction' : 'dir')
+ const require = createRequire(join(f.runtime, 'package.json'))
+ const original: unknown = require('plugin')
+ const next: unknown = require(b)
+ const originalModule = require.cache[a]
+ const nextModule = require.cache[b]
+ const engine = f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')
+ expect(original).toEqual({ marker: 'a' })
+ expect(next).toEqual({ marker: 'b' })
+ expect(require('plugin')).toBe(original)
+ const manifests = new PackageManifests()
+ disposers.push(() => { manifests.dispose() })
+ writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './b.cjs' }))
+ manifests.invalidate(manifest)
+ expect(require('plugin')).toBe(next)
+ expect(require.resolve('plugin')).toBe(b)
+ expect(require.cache[a]).toBe(originalModule)
+ expect(require.cache[b]).toBe(nextModule)
+ expect(require(a)).toBe(original)
+ expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')).toBe(engine)
+ expect(f.require(join(f.runtime, f.manifest))).toBe(engine)
+})
+
it('rejects an engine missing from the unpacked tree instead of using its archived copy', () => {
const f = fixture()
rmSync(join(f.root, 'app.asar.unpacked'), { recursive: true })
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index bcee8614ed..c92492333a 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:53`](../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..b1a1d8c5ae 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:53`](../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..85d1782354 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:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../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..f324d409e8 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:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../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..4f1eef45bf 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: 227c3c99c3c4b597
+ zh: 853b656340cb8bce
/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: 0f46d4df1dab7f3b
+ zh: d172835159a20cce
/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..513abc07b2 100644
--- a/packages/boot/hmr/README.md
+++ b/packages/boot/hmr/README.md
@@ -56,7 +56,15 @@ 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. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
+
+A changed `package.json` outside `node_modules` expires that package's cached configuration. A configuration-only manifest triggers no module reload; a manifest imported as a JSON module retains dependency-driven reloads, and one in the host dependency graph retains host reloads. Dedicated configuration watches keep their existing ownership. A new package entry takes effect when its Loader entry restarts.
+
+Loader entries retain their raw import results. HMR retains every distinct imported namespace for each entry name and configuration-tree base URL; an uninitialized entry does not overwrite another entry's namespace. HMR matches those objects to cached ModuleJob namespaces instead of resolving entry names again. Names without an imported namespace and `cordis:` builtins keep name-based resolution. Source reloads therefore use the loaded modules after `exports` or `main` changes.
+
+Entries sharing a runtime are replaced together, with each entry receiving the implementation for its own module. Duplicate entries for one module retain their separate plugin instances. On replacement failure, HMR removes partially activated replacements and restores the previous modules and plugin implementations. A successful reload updates every matching entry record, including disabled entries; a failed replacement leaves those records unchanged. Entries that have never loaded remain uninitialized.
+
+[package-manifest.ts](src/package-manifest.ts) owns the Node internal interfaces for package reads, scope, type, and nearest-manifest lookups. Each HMR instance owns its configuration cache and restores its installed hooks on disposal. Manifest invalidation clears the ESM `ResolveCache` and CommonJS `_pathCache`: neither records every consulted manifest, and entries may point outside their package directory. CommonJS loads resolve requests with Node's default resolver before entering the native loader, bypassing old request aliases while preserving evaluated modules. Synchronous resolve hooks receive the resulting filenames. Ordinary module replacement remains HMR's separate operation.
Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
@@ -86,7 +94,11 @@ 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.
+- TSX loaders and ordinary application-created Workers are outside the supported package-cache refresh scope.
+- CommonJS requests must be resolvable by Node's default resolver. Synchronous hooks may postprocess the resulting filenames, as Desktop's Office resolver does; hooks requiring the original request name or introducing virtual requests are unsupported. Builtins retain Node's original loading behavior.
+- Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again.
+- If a shared runtime's replacement modules have different plugin callbacks, an instance without a Loader entry cannot be assigned to one of them. HMR reports an ambiguity error and rolls back the reload.
- `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..ba99733ccf 100644
--- a/packages/boot/hmr/README.zh.md
+++ b/packages/boot/hmr/README.zh.md
@@ -56,7 +56,15 @@ 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 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
+
+`node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。
+
+Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。
+
+共享同一运行时的 entry 一起替换,每个 entry 使用其自身模块对应的实现。同一模块的重复 entry 仍保有各自的插件实例。替换失败时,HMR 清理已部分激活的替换实例,并恢复原来的模块和插件实现。重载成功后更新所有匹配的 entry 记录,包括已停用的 entry;替换失败时这些记录保持不变。从未加载过的 entry 仍保持未初始化状态。
+
+[package-manifest.ts](src/package-manifest.ts) 封装 package 读取、scope、type 和最近 manifest 查询所需的 Node internal 接口。每个 HMR 实例拥有自己的配置缓存,销毁时恢复它安装的 hook。manifest 失效会清空 ESM `ResolveCache` 和 CommonJS `_pathCache`:它们都不记录查询过的全部 manifest,入口也可能指向包目录之外。CommonJS 加载先用 Node 默认 resolver 解析请求,再进入原生 loader,绕过旧请求别名并保留已求值模块。同步 resolve hook 收到的是解析后的文件名。普通模块替换仍由 HMR 独立处理。
被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
@@ -86,7 +94,11 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
-- 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。
+- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。
+- TSX loader 和普通应用自行创建的 Worker 不在包缓存刷新支持范围内。
+- CommonJS 请求必须能由 Node 默认 resolver 解析。同步 hook 可以像 Desktop 的 Office resolver 一样处理解析后的文件名;依赖原始请求名或引入虚拟请求的 hook 不受支持。内置模块保留 Node 原有的加载行为。
+- 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。
+- 如果共享运行时的替换模块具有不同的插件回调,没有 Loader entry 的实例就无法确定应使用哪一个。HMR 会报告歧义错误并回滚此次重载。
- `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..cad957dbda 100644
--- a/packages/boot/hmr/src/index.ts
+++ b/packages/boot/hmr/src/index.ts
@@ -1,15 +1,17 @@
/** Serialized module and profile-configuration reloads. */
+import assert from 'node:assert/strict'
import { AsyncLocalStorage } from 'node:async_hooks'
import { watchConfig as watchExactConfig } from './watch-config.ts'
-import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
-import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
+import { Context, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis'
+import type { ModuleLoader, ModuleJob, 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'
@@ -79,9 +81,129 @@ async function loadDependencies(job: ModuleJob, ignored = new Set()) {
/** Module location and runtime retained during a replacement. */
export interface Reload {
filename: string
+ /** Original namespaces and URLs of all entry modules participating in this runtime replacement. */
+ modules: ReloadModules
runtime?: Plugin.Runtime | undefined
}
+/** Entry names with distinct namespaces; iteration falls back to undefined only for names without a loaded namespace. */
+class EntryNamespaces {
+ private readonly names = new Map>()
+
+ constructor(private internal: ModuleLoader, private moduleUrls: ReadonlyMap) {}
+
+ static getModuleUrls(internal: ModuleLoader): Map {
+ const moduleUrls = new Map()
+ for (const url of internal.loadCache.keys()) {
+ const job = internal.loadCache.get(url)
+ if (!job?.module) continue
+ try {
+ moduleUrls.set(job.module.getNamespace(), url)
+ } catch (_error) {
+ // Node also caches unfinished jobs whose namespace is not available yet.
+ }
+ }
+ return moduleUrls
+ }
+
+ add(name: string, moduleNamespace: unknown): this {
+ const namespaces = this.names.get(name) ?? new Set()
+ namespaces.add(moduleNamespace)
+ this.names.set(name, namespaces)
+ return this
+ }
+
+ *[Symbol.iterator](): Generator<[name: string, moduleNamespace: unknown]> {
+ for (const [name, namespaces] of this.names) {
+ for (const moduleNamespace of namespaces) {
+ if (moduleNamespace === undefined && namespaces.size > 1) continue
+ yield [name, moduleNamespace]
+ }
+ }
+ }
+
+ async resolve(name: string, baseUrl: string, moduleNamespace: unknown): Promise> {
+ if (moduleNamespace === undefined || name.startsWith('cordis:')) {
+ switch (this.internal.version) {
+ case 'v1': return await this.internal.resolve(name, baseUrl, {})
+ case 'v2': return this.internal.resolveSync(baseUrl, { specifier: name, attributes: {} })
+ }
+ }
+ const url = this.moduleUrls.get(moduleNamespace)
+ if (url === undefined) throw new Error(`HMR cannot locate the loaded module for ${name} from ${baseUrl}`)
+ return { url }
+ }
+}
+
+type ReloadFiber = {
+ fiber: Fiber
+ entry: Fiber['entry']
+ config: unknown
+ moduleNamespace: unknown
+}
+
+type ReloadModule = { filename: string; moduleNamespace: unknown; plugin: Plugin }
+
+/** Entry modules belonging to one runtime, keyed by their original namespaces across replacement. */
+class ReloadModules {
+ private readonly modules = new Map()
+
+ static include(reloads: ReadonlyMap, plugin: Plugin, job: ModuleJob, runtime: Plugin.Runtime | undefined): boolean {
+ let info = reloads.get(plugin)
+ if (info === undefined && runtime) info = [...reloads.values()].find(info => info.runtime === runtime)
+ if (info === undefined) return false
+ info.modules.add(job, plugin)
+ return true
+ }
+
+ add(job: ModuleJob, plugin: Plugin): this {
+ assert(job.module, `HMR pending module is missing: ${job.url}`)
+ const moduleNamespace: unknown = job.module.getNamespace()
+ this.modules.set(moduleNamespace, { filename: job.url, moduleNamespace, plugin })
+ return this
+ }
+
+ async importRemaining(loader: Context['loader'], getOuterStack: () => string[], primary: ReloadModule): Promise {
+ const replacements = new ReloadModules()
+ for (const [originalNamespace, { filename }] of this.modules) {
+ if (filename === primary.filename) {
+ replacements.modules.set(originalNamespace, primary)
+ continue
+ }
+ const moduleNamespace: unknown = await loader.import(filename, getOuterStack)
+ const plugin = loader.unwrapExports(moduleNamespace) as Plugin
+ replacements.modules.set(originalNamespace, { filename, moduleNamespace, plugin })
+ }
+ return replacements
+ }
+
+ *getActiveImplementations(ctx: Context, plugin: Plugin, fibers: readonly ReloadFiber[]): Generator<[ReloadFiber, Plugin]> {
+ const callbacks = new Set([...this.modules.values()].map(value => ctx.registry.resolve(value.plugin)))
+ const implementations = fibers.map((previousFiber) => {
+ if (previousFiber.entry === undefined) return [previousFiber, plugin] as const
+ const replacement = this.modules.get(previousFiber.moduleNamespace)
+ const implementation = replacement ? replacement.plugin : ctx.loader.unwrapExports(previousFiber.moduleNamespace) as Plugin
+ callbacks.add(ctx.registry.resolve(implementation))
+ return [previousFiber, implementation] as const
+ })
+ for (const [previousFiber, implementation] of implementations) {
+ if (previousFiber.fiber.parent.fiber.uid === null) continue
+ if (previousFiber.entry === undefined && callbacks.size > 1) {
+ throw new Error('HMR replacement is ambiguous for a plugin instance without a Loader entry')
+ }
+ yield [previousFiber, implementation]
+ }
+ }
+
+ updateEntries(loader: Context['loader']): void {
+ for (const [originalNamespace, { moduleNamespace }] of this.modules) {
+ for (const entry of loader.entries()) {
+ if (entry.moduleNamespace === originalNamespace) entry.moduleNamespace = moduleNamespace
+ }
+ }
+ }
+}
+
/** Hot reload service with Cordis-compatible module configuration and events. */
@Inject('loader')
@Inject('timer')
@@ -131,6 +253,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.
@@ -185,22 +308,13 @@ class Hmr extends Service {
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
}
- /**
- * Resolve a module specifier to a URL, compatible with Node 22-24.
- */
- private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise {
- switch (this.internal.version) {
- case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
- case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
- }
- }
-
async* [Service.init](): AsyncGenerator<() => Promise, void, unknown> {
yield async () => {
this.closing = true
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,15 +390,20 @@ 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
+ const isManifest = basename(filename) === 'package.json' && !filename.includes(`${sep}node_modules${sep}`)
+ if (isManifest) {
+ this.manifests.invalidate(filename)
+ }
const url = pathToFileURL(filename).href
if (this.externals.has(url)) {
fullReload = true
continue
}
- if (this.internal.loadCache.has(url)) {
+ if (this.internal.loadCache.has(url) || this.internal.loadCache.has(url, 'json')) {
this.stashed.add(url)
continue
}
+ if (isManifest) continue
const include = [...loader.entries()].map(entry => entry.subtree as Include | undefined)
.find(tree => tree?.filename === filename || tree?.filename === configuredFilename)
if (include !== undefined) includes.add(include)
@@ -408,23 +527,24 @@ class Hmr extends Service {
const pending = new Map()
const reloads = new Map()
+ const moduleUrls = EntryNamespaces.getModuleUrls(this.internal)
// Build a map of plugin names per config tree URL.
// Plugin entry files are treated as atomic reload units.
- const nameMap = new Map>()
+ const nameMap = new Map()
for (const entry of this.ctx.loader.entries()) {
const baseUrl = entry.parent.tree.ctx.baseUrl
if (baseUrl === undefined) throw new Error('HMR entry tree has no base URL')
- const names = nameMap.get(baseUrl) ?? new Set()
- names.add(entry.options.name)
+ const names = nameMap.get(baseUrl) ?? new EntryNamespaces(this.internal, moduleUrls)
+ names.add(entry.options.name, entry.moduleNamespace)
nameMap.set(baseUrl, names)
}
- // Resolve each plugin name to its file URL and check if it needs reload
+ // Find each plugin's loaded URL and check if it needs reload.
for (const [baseUrl, names] of nameMap) {
- for (const name of names) {
+ for (const [name, moduleNamespace] of names) {
try {
- const { url } = await this._resolve(name, baseUrl, {})
+ const { url } = await names.resolve(name, baseUrl, moduleNamespace)
if (this.declined.has(url)) continue
const job = this.internal.loadCache.get(url)
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace()) as Plugin | undefined
@@ -443,15 +563,27 @@ class Hmr extends Service {
const dependencies = [...await loadDependencies(job, this.declined)]
this.declined.add(job.url)
+ if (dependencies.length === 0) {
+ pending.delete(job)
+ continue
+ }
if (!dependencies.some(dep => this.accepted.has(dep))) continue
dependencies.forEach(dep => this.accepted.add(dep))
+ const runtime = this.ctx.registry.get(plugin)
+ if (ReloadModules.include(reloads, plugin, job, runtime)) continue
reloads.set(plugin, {
filename: job.url,
- runtime: this.ctx.registry.get(plugin),
+ runtime,
+ modules: new ReloadModules().add(job, plugin),
})
}
+ // Re-export roots of a replaced runtime must not retain bindings to its old modules.
+ for (const [job, plugin] of pending) {
+ if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url)
+ }
+
/**
* Clear module caches for all accepted files before re-importing.
*
@@ -502,14 +634,23 @@ class Hmr extends Service {
fibers: [...info.runtime?.fibers ?? []].map((fiber) => {
const entry = fiber.entry?.fiber?.uid === fiber.uid ? fiber.entry : undefined
const config: unknown = entry === undefined ? fiber._config : entry.options.config
- return { fiber, entry, config }
+ return { fiber, entry, config, moduleNamespace: entry?.moduleNamespace }
}),
}))
- const attempts: Array<(typeof generations)[number] & { replacement: Plugin }> = []
+ type ReloadAttempt = (typeof generations)[number] & {
+ replacement: Plugin
+ replacements: ReloadModules
+ activated: Fiber[]
+ }
+ const attempts: ReloadAttempt[] = []
try {
for (const generation of generations) {
- const replacement = this.ctx.loader.unwrapExports(await this.ctx.loader.import(generation.filename, this.getOuterStack)) as Plugin
- attempts.push({ ...generation, replacement })
+ const moduleNamespace: unknown = await this.ctx.loader.import(generation.filename, this.getOuterStack)
+ const replacement = this.ctx.loader.unwrapExports(moduleNamespace) as Plugin
+ const replacements = await generation.modules.importRemaining(this.ctx.loader, this.getOuterStack, {
+ filename: generation.filename, moduleNamespace, plugin: replacement,
+ })
+ attempts.push({ ...generation, replacement, replacements, activated: [] })
}
} catch (e) {
handleError(this.ctx, e)
@@ -517,11 +658,14 @@ class Hmr extends Service {
throw e
}
- const reload = async (plugin: Plugin, fibers: (typeof generations)[number]['fibers']) => {
- const activated = []
- for (const previousFiber of fibers) {
- if (previousFiber.fiber.parent.fiber.uid === null) continue
- const fiber = previousFiber.fiber.parent.registry.plugin(plugin, previousFiber.config, this.getOuterStack).ctx.fiber
+ const reload = async (
+ plugin: Plugin,
+ fibers: (typeof generations)[number]['fibers'],
+ replacements = new ReloadModules(),
+ activated: Fiber[] = [],
+ ) => {
+ for (const [previousFiber, implementation] of replacements.getActiveImplementations(this.ctx, plugin, fibers)) {
+ const fiber = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack).ctx.fiber
if (previousFiber.entry !== undefined) {
fiber.entry = previousFiber.entry
previousFiber.entry.fiber = fiber
@@ -533,7 +677,7 @@ class Hmr extends Service {
const removed = new Set()
try {
- for (const { previous: plugin, replacement, filename, runtime, fibers } of attempts) {
+ for (const { previous: plugin, replacement, filename, runtime, fibers, replacements, activated } of attempts) {
if (!runtime) continue
const path = relative(this.baseDir, fileURLToPath(filename))
@@ -547,7 +691,7 @@ class Hmr extends Service {
}
try {
- await reload(replacement, fibers)
+ await reload(replacement, fibers, replacements, activated)
this.ctx.logger.info('reload plugin at %C', path)
} catch (err) {
this.ctx.logger.warn('failed to reload plugin at %C', path)
@@ -558,14 +702,17 @@ class Hmr extends Service {
} catch (error) {
// Restore caches and re-register old plugins after a replacement failure.
rollback()
- for (const { previous: plugin, replacement, fibers } of attempts) {
+ for (const { previous: plugin, fibers, activated } of attempts) {
if (!removed.has(plugin)) continue
try {
- const replacementRuntime = this.ctx.registry.get(replacement)
- const replacementFibers = [...replacementRuntime?.fibers ?? []]
- this.ctx.registry.delete(replacement)
- // Failed startup errors remain on fibers after their teardown finishes.
- await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
+ for (const { runtime } of activated) {
+ if (runtime && this.ctx.registry.get(runtime.callback) === runtime) {
+ const replacementFibers = [...runtime.fibers]
+ this.ctx.registry.delete(runtime.callback)
+ // Failed startup errors remain on fibers after their teardown finishes.
+ await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
+ }
+ }
await reload(plugin, fibers)
} catch (err) {
this.ctx.logger.warn(err)
@@ -575,6 +722,9 @@ class Hmr extends Service {
}
await this.ctx.loader.await()
+ for (const { replacements } of attempts) {
+ replacements.updateEntries(this.ctx.loader)
+ }
this.ctx.emit('hmr/reload', reloads)
this.stashed = new Set()
}
diff --git a/packages/boot/hmr/src/package-manifest.ts b/packages/boot/hmr/src/package-manifest.ts
new file mode 100644
index 0000000000..0c41fbb42c
--- /dev/null
+++ b/packages/boot/hmr/src/package-manifest.ts
@@ -0,0 +1,330 @@
+/** 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, isBuiltin } 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
+ _resolveFilename(request: string, parent: NodeJS.Module | null | undefined, isMain?: boolean): string
+ _load(request: string, parent: NodeJS.Module | null | undefined, isMain?: boolean, ...options: unknown[]): unknown
+}
+
+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