DSH plugin HMR: "--expose-internals is required"
If you run pnpm install && pnpm run build && pnpm dsh web from the source repository as documented, the process exits immediately with --expose-internals is required for HMR service — and the missing flag is only the surface. The real chain is this: the web bundle disables the shared HMR row, but boot's closing step mounts a root: [] watch-only HMR instance anyway; that instance does not need loader.internal at all, yet its constructor demands it unconditionally; and the product's intended native helper fails to resolve under a pnpm layout, with the failure swallowed by an empty catch {} (#2699).
DSH plugin symptom: pnpm dsh web exits immediately
What misleads most about this error is that it presents a development-time Node switch as a hard requirement while saying nothing about the actual failure — the native helper being unavailable. Concretely:
- The repro is the path from the official docs:
pnpm install→pnpm run build→pnpm dsh web. Expected: the Web UI starts onhttp://127.0.0.1:3080. Actual: the process exits immediately, printing:
Error: failed to apply loader entry ... (@deepseek-ai/cordis-plugin-hmr):
--expose-internals is required for HMR service
[cause]: Error: --expose-internals is required for HMR service
at runProfile (apps/cli/src/profile-boot.ts:283)
at new Hmr (vendor/hmr/src/index.ts:121)
(#2699)
2. It is unrelated to the repository path: reproducible on Windows 11 with Node v22.22.2 (via pnpm) and also on v24.15.0, at 0.1.0-rc.5 (commit 47f943859b). Worth stating up front — this is not about a path containing non-ASCII characters, it is purely about launch arguments and dependency state (#2699).
3. The root package.json's dsh script really is missing the flag: line 136 reads "dsh": "node --import tsx/esm apps/cli/src/bin.ts", with no --expose-internals. Changing it to node --expose-internals --import tsx/esm apps/cli/src/bin.ts makes pnpm dsh web start (verified locally) — so this is one genuine issue, but only the surface (#2699).
4. Why "the web bundle already disabled HMR" is not a rebuttal: that comment in packages/bundle/web-app/cordis.patch.yml — about re-enabling shared HMR once the web reload lifecycle is tested — only says the shared module-reload row is disabled. The watch-only remount is a different piece of code, and it is triggered precisely when the composition leaves no HMR service (#2699).
5. The failure kills the whole startup rather than just HMR: the mount happens after the plugin tree is already ACTIVE, and suppressShutdownError (defined at apps/cli/src/profile-boot.ts:195, called at :296) rethrows setup errors while the fiber is alive. Note the location: it is not in vendor/hmr/src/index.ts — a detail corrected during the discussion (#2699).
DSH plugin mechanism: the web bundle disables HMR, then boot remounts a watch-only instance
Only by connecting "who demands internal", "why internal is undefined", and "why the failure kills startup" do all four layers of this bug become visible. Layer by layer:
- The remount point is in boot's closing step: the comment at
apps/cli/src/profile-boot.ts:272-283is explicit — the web bundle disables the shared module-reloadhmrrow, so when the composition leaves no HMR service, mount a watch-only instance with no module roots:
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
}
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
}
And the HMR constructor at vendor/hmr/src/index.ts:120-121 demands internal unconditionally:
if (!this.ctx.loader.internal) {
throw new Error('--expose-internals is required for HMR service')
}
(#2699)
2. The internal probe actually has two paths; the error mentions one: vendor/loader/src/internal.ts:108-117 — ① only when process.execArgv already carries --expose-internals does it require('internal/modules/esm/loader'); ② otherwise it goes through require('node-addon-require-builtin').requireBuiltin(id); ③ if both fail it returns undefined. The error text turns a development-time Node switch into a hard requirement while hiding the product's intended native-helper path (#2699).
3. Why the helper is unavailable under a pnpm layout: apps/cli/package.json declares node-addon-require-builtin as a formal CLI dependency, while in vendor/loader/package.json it is only an optional peer. fromInternal() uses createRequire(import.meta.url), so resolution starts from the loader package's own directory, not apps/cli — under pnpm's isolated layout the CLI can have it while the loader's require() still fails, and the failure is eaten by an empty catch {}. Community testing went further: the JS wrapper actually does resolve under the pnpm layout (there is a symlink on the vendor/loader side), but calling requireBuiltin() throws No usable native binding found — there is no .node binding file in the main package directory (#2699).
4. How the binding is supposed to land (and why it works for some and not others): allowBuilds.node-addon-require-builtin: false in pnpm-workspace.yaml is actually a no-op for this package (it has only build:js and no install script, so allowing it means nothing). The binding lands via the platform package (such as node-addon-require-builtin-win32-x64-msvc) whose prebuilt is copied into %LOCALAPPDATA%/node-addon-native-custom-loader/native-cache and loaded from there; a pnpm version mismatch (for example an older pnpm on PATH) breaks the platform package link and fails closed. Installing correctly with corepack [email protected] made requireBuiltin() return the module with the binding in place — which may be the precise explanation for the difference between user environments and official CI (#2699).
5. The architectural mismatch is the root cause: watch-only does not need internal at all. There are 6 uses of this.internal in HMR: the constructor check at L120-121, init at L221, _resolve at L193-195, getLinked at L332, the reload block at L419, and backup/rollback at L466-484. Only L121 and L221 are necessarily reached with root: []; the other four sit on the module-reload path that watch-only never takes. And what watch-only actually does — registerConfig watching config through chokidar — never touches this.internal (#2699).
6. A landing constraint that must change alongside it: even if the constructor's unconditional check is removed, [Service.init] still runs this.internal.loadCache.get(mainUrl) first when root is empty. Removing only the check without making that loadCache read optional turns watch-only from today's --expose-internals error into a TypeError, and startup still dies — the two must change together (#2699).
7. There is also a third line of defence worth knowing: even if the main watcher does receive an event, the config-reload branch at L250-253 inside onChange hits first and returns early (because cordis.patch.yml is a loader include), so L265's loadCache.has is genuinely unreachable. That reinforces that watch-only's necessarily-reached paths are exactly those two (#2699).
DSH plugin fix: decouple watch-only from module-reload, plus the flag, dependency, and gate layers
The correct answer is a root-branched decoupling, not degradation or relaxing every check. Specifically:
- P0 architecture decoupling (the only root fix that lets
dsh webstart without the flag): in the watch-only branch (root: []) skip the constructor's internal check and guard the init-blockloadCacheread; keep the original check in the module-reload branch — L193-484 all depend oninternal, and relaxing globally would make normal reload fail silently, which is worse than an error. The landing list is: type nullable atL91, constructorL120/L123, initL221, and nothing else (#2699). - Explicitly retract "log and degrade": the comment at
profile-boot.ts:276-277saysA silent skip would break the documented hot-reload contract— upstream treats hot reload as a documented contract, so degrading silently breaks the contract and contradicts the design intent. The only first-principles fix is architectural decoupling, so the contract also holds without the flag (#2699). - The immediately usable bypass: launch with the flag. Note that
--expose-internalscannot go intoNODE_OPTIONS— the repo'senginesis^22.19.0 || >=24.0.0, on which Node rejects it outright, andinternal.tsonly checksprocess.execArgv.includes('--expose-internals'), which means CLI argv. So the correct form is:
node --expose-internals --import tsx/esm apps/cli/src/bin.ts web
This only routes the loader through Node internals; it is neither a hardening measure nor a long-term fix (#2699).
4. P1 dependency fix: promote the helper from the loader's optional peer to the loader's own dependency, or move the require starting point into the CLI install tree, so a pnpm isolated layout cannot produce "the CLI has it, the loader cannot see it"; and fix the prebuilt distribution lookup chain so the failure reason is diagnosable (#2699).
5. P1 error visibility: an empty catch should at minimum log, and the error text should mention the node-addon-require-builtin resolution failure rather than only --expose-internals. One governance finding is worth recording here: vendor code is doubly invisible to lint — staged oxlint excludes vendor/*/src/** (lefthook.yml:20) and .oxlintrc.json:25's ignorePatterns also excludes vendor/**; more importantly categories.correctness is "off" wholesale (.oxlintrc.json:5), and the no-empty rule for empty catches belongs to that category. So the AGENTS.md discipline that "an empty catch must say what it swallowed" is effectively void for vendor code (#2699).
6. P0 release process: put the official golden path into CI. Today's tests cannot construct this crash path: the test at apps/cli/tests/built-bin.e2e.ts:329 explicitly avoids activating startup-dependent rows (it covers --help / --host 0.0.0.0); packages/boot/app-boot/tests/hmr-config.spec.ts does test HMR config reload with a default root of [], but it boots via a test Context() plus the Loader plugin, where the test loader's internal always exists (it can assert ctx.loader.internal!.loadCache.has successfully); and CI's nodeCompatSmokeGates() only runs source-worker / jsonl-zstd / source-launch / vitest-jsdom compatibility smokes, none of which is a real dsh web launch. So the internal === undefined path is simply unreachable in the test environment — adding a golden-path smoke that really starts on 127.0.0.1:3080 would catch "the documented command does not start" before release (#2699).
7. A blind spot in rc.7's new gate (governance layer): the newly added verify-optional-dependency-imports (commit 7b973e27) is designed to catch "module-scope static imports of optional dependencies", but #2699 falls squarely into its three blind spots — ① the PUBLISHED_SOURCE regex only matches packages/*/*/src and apps/*/src (:36), so vendor is not scanned at all, and loader/internal.ts sits in the vendor/loader exemption zone; ② it walks only AST ImportDeclaration / ExportDeclaration (L171-172), so the loader's runtime require() plus empty catch is out of scope; ③ the gate's own docs concede that dynamic import is a "last resort" (L15), and the loader is exactly that pattern while remaining undetected; its fixtures are all packages/f/* static-import cases with no vendor and no dynamic require. This is the systemic blind spot of "strict governance for our own code, trust assumptions for vendor" (#2699).
8. What this means for plugin authors: when you distribute plugins through DSH Plugin Hub, or write your own HMR-style watcher plugin, never let the absence of an optional capability become a fatal failure — especially when that capability is genuinely needed on only one branch. The shape given here is the answer: hold the dependency per branch, require it only where it is needed, and do not use an empty catch to swallow the "why did we not get it" information (#2699).
DSH plugin troubleshooting notes
Remember first that the missing flag is only the surface — the real failure is the watch-only HMR instance that boot remounts on its closing step hitting a dependency it never needed, so adding the flag is not the answer. Eight points to keep in mind when a DeepSeek Harness plugin hits this boot error:
- The missing flag is only the surface: the real failure is the watch-only HMR remount plus the native helper resolution failure.
- The web bundle disabling HMR does not mean no HMR is mounted: boot's closing step adds a
root: []watch-only instance. NODE_OPTIONScannot carry this flag: it must be on CLI argv.- Degrading is not the answer: hot reload is a documented contract, and a silent skip contradicts the design intent.
- Two places must change together: the constructor check and
[Service.init]'sloadCacheread, otherwise the error becomes aTypeError. - Decouple per root branch: relaxing globally makes normal module reload fail silently, which is worse than an error.
- Tests cannot construct this path: the test loader's
internalalways exists, so a golden-path smoke with a real launch is needed. - The pnpm version affects the binding: a broken platform-package link fails closed; aligning versions via corepack rules out the environment variable.

Sources: Discussion #2699, PR #576.
FAQ
In a DSH plugin the web bundle disabling the shared HMR row does not mean no HMR gets mounted, so this error can still fire on startup. packages/bundle/web-app/cordis.patch.yml does set the hmr row to disabled: true, but after boot() succeeds apps/cli/src/profile-boot.ts:272-283 checks ctx.get('hmr'); finding no HMR service in the composition, it **mounts a root: [] watch-only instance** just to watch cordis.patch.yml. That is where the error enters (Source: Discussion #2699).
In a DeepSeek Harness plugin the mount happens after the plugin tree is already ACTIVE, so a single failure takes down the whole startup rather than only HMR. Specifically, suppressShutdownError (defined at apps/cli/src/profile-boot.ts:195, called at :296) **rethrows setup errors while the fiber is still alive**, so the whole startup fails — rather than "HMR unavailable but the web UI still comes up". That is also why you cannot simply log and degrade: the comment at profile-boot.ts:276-277 says A silent skip would break the documented hot-reload contract, and hot reload is treated as a documented contract (Source: Discussion #2699).
In a DSH plugin the --expose-internals message tells only half the truth: the flag gets things running, but that is not the product's intended path. The probe in vendor/loader/src/internal.ts:108-117 goes: only when process.execArgv **already carries** --expose-internals does it require('internal/modules/esm/loader'); otherwise it calls require('node-addon-require-builtin').requireBuiltin(id); and only if both fail does it return undefined. So the product's intended path is actually that native helper, while the message hides it and mentions only a development-time Node switch. Adding the flag gets it running, but that is **not the intended path and not a long-term fix** (Source: Discussion #2699).
The DeepSeek Harness maintainers believed it was fixed because PR #576 (eb0cc4eb18, "drop obsolete --expose-internals launches"), merged on 7/23, removed the flag from bin/dsh and tutorial 06. Tests were touched at the time — the loader-smoke case prepends --expose-internals was deleted — but **no contract test was added for "boot without the flag must not crash when the binding is missing"**. Upstream assumed the node-addon is always available, while the install state under a pnpm layout made it unavailable. So it is not "untested" but "the tests only covered argument construction on the happy path, not the dependency-missing path" (Source: Discussion #2699).
Related Terms
- watch-only HMR
- An HMR instance that only watches config-layer changes such as cordis.patch.yml and reloads no modules (config root: []). It should not need loader.internal at all, yet the constructor's unconditional check makes it depend on that chain — the architectural mismatch at the heart of this bug.— https://github.com/deepseek-ai/deepseek-harness/discussions/2699
- loader.internal
- The ModuleLoader entry point that exposes Node's internal ESM loader. Its probe has two steps: with --expose-internals in execArgv it uses require('internal/modules/esm/loader'), otherwise node-addon-require-builtin's requireBuiltin(); when both fail it returns undefined, and two empty catches swallow the reason.— https://github.com/deepseek-ai/deepseek-harness/discussions/2699
- golden-path smoke
- Putting the command from the official docs — pnpm dsh web actually starting on 127.0.0.1:3080 — into CI as a smoke test. Today's e2e deliberately avoids activating startup-dependent rows and the compatibility gates never run a real dsh web, so "the documented command does not start" ships unnoticed.— https://github.com/deepseek-ai/deepseek-harness/discussions/2699
Sources
- deepseek-harness Discussion #2699: pnpm dsh web fails to start with --expose-internals is required for HMR service· deepseek-ai (GitHub Discussions)
- PR #576 drop obsolete --expose-internals launches (commit eb0cc4eb18)· GitHub (deepseek-ai)