DSH / Atlas
2026-09-19implementedarchitecture

Resolve profile modules along the ancestor chain with an interception layer

profile 解析的主线祖先链与拦截层

A profile loads plugins from its own package project. The dsh installation packages and the dependencies embedded in bundles are not on the profile's dependency tree, so Node's default `node_modules` lookup starting from the profile cannot find them. A developer must be able to restate the resolution rule in one sentence: which part is ordinary Node behavior, which part is dsh's intervention, and where that intervent

English

Problem

A profile loads plugins from its own package project. The dsh installation packages and the dependencies embedded in bundles are not on the profile's dependency tree, so Node's default node_modules lookup starting from the profile cannot find them.

A developer must be able to restate the resolution rule in one sentence: which part is ordinary Node behavior, which part is dsh's intervention, and where that intervention ends. The previous runtime implementation hid the whole $DSH_HOME/profiles/node_modules layer for every package name and kept dedicated recognition code for historical directory layouts, so the rule could not be stated in one sentence and nobody could judge whether a given resolution result was expected.

Decision

Ordinary module requests retain Node's ancestor node_modules order. Inside a profile, runtime resolution entries occupy their package positions at $DSH_HOME/profiles/node_modules. For an importer in a recorded external linked directory, each candidate D/node_modules applies one rule: a name declared in D/package.json under peerDependencies and present in the runtime resolution uses the runtime package at that position; otherwise Node tries the physical package there. A nearer candidate precedes a peer declaration farther up. Explicit CommonJS paths always bypass interception. Node owns the remaining resolution behavior. Construction, publication, Worker inheritance, and runtime carriers of immutable generations are recorded by the existing Note and are not repeated here.

Part 1: Resolution rules

1. The ancestor chain: Node's ancestor node_modules lookup

The importer is the module file that issues the import or require. Node starts from the importer's directory and searches ancestor node_modules positions up to the filesystem root, skipping a redundant node_modules/node_modules position. CommonJS also searches NODE_PATH and its global directories; ESM does not. The nearest matching candidate wins, subject to Node's package-entry and failure rules.

Symlinks are followed to their real path per Node's default behavior. Once a module is loaded by its real path, the ancestor chain for its subsequent imports is the real path's ancestor chain, regardless of where it was linked from.

The chain below defines the numbering that every table in this Note references. The importer is $DSH_HOME/profiles/web/node_modules/my-plugin/index.js:

① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
② $DSH_HOME/profiles/web/node_modules
③ $DSH_HOME/profiles/node_modules
④ $DSH_HOME/node_modules
⑤ /node_modules

① holds the plugin's private dependencies, ② the packages the profile installed, ③ the interception layer where the runtime resolution occupies the package directories it has entries for, ④ the Harness home, and ⑤ the filesystem root. CommonJS then retains its NODE_PATH and global-directory lookup without peer interception at those extra positions.

2. The interception layer: the runtime resolution occupies $DSH_HOME/profiles/node_modules/<package>

The interception layer is the first ancestor node_modules above the importer's profile directory ($DSH_HOME/profiles/<name>). For every profile inside the tree this layer is $DSH_HOME/profiles/node_modules.

Every package name in the runtime resolution occupies the package directory position <interception layer>/<package> at this layer. For an installation package name such as @deepseek-ai/dsh-tools, the package directory at ③ is the copy of the running installation; an old link or old directory of the same name on disk is no longer a member of the chain. For a package name absent from the runtime resolution, the package directory at ③ is the physical content on disk.

The order for one bare package name request: the ancestor chain first walks the layers inside the profile directory (① and ②), and when a candidate exists Node resolves inside that candidate and returns. On reaching ③, if the runtime resolution has an entry for that package name, Node resolves inside the package directory the entry records; a hit returns, and a missing file inside the package continues per Node's own handling of a "found package": CommonJS keeps looking for the subpath layer by layer from ④, ESM fails immediately. If the runtime resolution has no entry, Node looks at the physical directory at ③ and then moves on to ④.

Node remains responsible for exports, imports, conditions, main, subpaths, extensions, caching, and error codes. When the selected package rejects a subpath through exports, the error is final; Node does not switch to another package of the same name.

The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the runtime resolution, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's node_modules whose target lies inside <profile>/.dsh-module-fallback/node_modules, followed by that directory. pnpm-installed packages and every other symlink stay.

3. Linked directories: peer interception at each ancestor position

When <profile>/node_modules/<package> links to a real directory R outside the profiles tree, R is a linked root. R need not contain package.json or node_modules: it may be a package directory, a monorepo package, or a src directory. Importers below R follow Node's real ancestor chain. The recorded root decides which importers participate; it does not stop their lookup at R.

Linked interception excludes importers inside installation-scope package directories, using both their configured and real paths. A broad checkout link therefore does not take over the host's own dependency lookup. Overlapping linked roots describe one participation scope: the importer determines the ancestor chain, so neither root order nor link names choose a different peer set.

At each candidate D/node_modules, a matching current D/package.json peer declaration uses the runtime package if the table supplies that name. This occupies only that package's position, including when D/node_modules does not physically exist. Without both a declaration and a runtime entry, Node tries the physical candidate. A missing manifest does not stop lookup. dependencies and devDependencies do not enable interception, and a peer version range is not an additional resolver filter. The same rule applies to third-party peers such as React when the runtime table supplies them.

A nearer physical candidate wins before any later ancestor peer declaration. If a selected package's exports rejects the request, Node's error is final. A legacy CommonJS subpath miss can continue to the next position, where that directory's peer rule applies again; the physical copy at an occupied position is not retried. Each new linked resolution reads the manifests at the positions it visits, without memoizing its selected route. An unreadable manifest supplies no peers; Node retains its native manifest diagnostics. This does not clear Node's caches or automatically reload already loaded modules.

Runtime resolution construction scans top-level and @scope/* links in <profile>/node_modules. A successor may add or remove linked roots within the existing package-mapping and local-name constraints. Removal needs no restart: a directory no longer covered by any root returns to native lookup for subsequent resolutions. The generation rules define publication, existing module references, and relink checks across removal. A dependency whose real path lies outside every recorded linked root remains outside interception, even when a plugin links to it.

4. What the interception layer holds: the runtime resolution's scan contents

The runtime resolution is computed once at profile startup and consists of four parts.

  • Installation closure: starting from the package.json of the currently running dsh package, a breadth-first traversal follows dependencies and peerDependencies; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The closure holds several hundred entries, roughly half in the @deepseek-ai/ scope and half third-party libraries. These entries apply to every profile.
  • Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root.
  • Local package names: package names among the profile's direct dependencies that are already installed in $DSH_HOME/profiles/<name>/node_modules. They already sit at ② on the ancestor chain; recording them only saves one directory probe.
  • Linked roots: top-level and @scope/* links in $DSH_HOME/profiles/<name>/node_modules whose targets are directories outside both the shared profiles tree and the active profile directory; each records its link name and real directory. Their own manifests are optional. Missing targets and files are excluded. An application-owned profile outside the shared tree does not register its internal pnpm store links as external roots.

The CLI derives the installation anchor from import.meta.url, which Node has already resolved to its real path; the Desktop Host builds it from its runtime directory, which is not a symlink. Both leave the installation root equal to its real directory, so bundle discovery and dependency traversal use the same location. During the traversal, every dependency level uses the real directory of its owning package as the lookup anchor for the next level and records the location of the manifest that declares it. After a hit, Node resolves from that declaring location and gets the same result the package's own internal imports get. Every entry records the package name, package directory, version, declaring location, and scope. Declared but uninstalled dependencies are skipped; the bundle package root itself does not become an entry.

5. Hook coverage

The hook participates only when the importer is under $DSH_HOME/profiles/** or a linked root. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside both locations, go straight to Node. require.resolve(request, { paths }) also goes straight to Node, including when a path names a profile or linked directory.

The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same runtime resolution. The implementation lives in packages/boot/app-boot/src/profile-resolution/resolver.ts; runtime resolution construction lives in packages/boot/app-boot/src/profile.ts.

6. Package directories and module files

packageDir('pkg', parentURL) and packageDir('pkg/sub', parentURL) locate the package directory without loading code or validating the subpath. Ordinary entry and file requests still ask Node to apply exports, main, conditions, and file existence. A package-directory query can therefore succeed while importing a missing or unexported file fails. import.meta.resolve can return a URL for an exported file that does not exist; importing that URL still fails, as in native Node.

Part 2: Walkthroughs

Table 1: Ancestor chain and interception for ESM and CommonJS across request forms

The table uses the installation package @deepseek-ai/dsh-tools (has a runtime resolution entry) and the third-party library left-pad (no runtime resolution entry) as examples; pkg stands for either.

Request formESMCommonJSPosition of the package name at ③When a file is missing inside the package
Bare name pkg, package has exportsIf ① or ② has a pkg directory, Node takes the entry point from its exportsNode selects the require condition from the same candidateWith entry: the runtime resolution's package; without entry: the physical directoryA missing exported target is final
Bare name pkg, package has no exportsEntry point from main or indexNative legacy entry lookupAs aboveESM fails, final; CommonJS continues only for a native lookup miss, not an invalid declared main
Subpath pkg/sub, package has exportsThe first pkg found decides; if ./sub is not exported, ERR_PACKAGE_PATH_NOT_EXPORTED is finalSame as ESMAs aboveFinal; no other copy is searched
Subpath pkg/sub, package has no exportsIf the first pkg found has no sub, ERR_MODULE_NOT_FOUND is finalTries <layer>/pkg/sub layer by layer: ① and ②, then the position of the package name at ③ (the runtime resolution's package directory when an entry exists), then ④As aboveESM final; CommonJS continues to the next layer, that is ④
#alias package-internal aliasAfter the owning manifest's imports maps it to a bare name, handled per the rows above; a mapping to a relative path goes straight to NodeSame as ESM, with conditions from the caller or the defaultsSame as the matching rowSame as the matching row
require.resolve(pkg, { paths })No such formEntire request goes to Node with the original options, regardless of the paths' locationsNo interception; physical contents onlyNative Node continuation and errors
Package self-reference (the importer's package imports by its own name)Goes to Node with the original importer keptSame as ESMNot involvedNot applicable
Relative / absolute / URL / builtinGoes to NodeGoes to NodeNot involvedNot applicable

After a runtime resolution hit, the ERR_MODULE_NOT_FOUND and ERR_PACKAGE_PATH_NOT_EXPORTED errors Node reports replace the internal declaring location with the original importer; the CommonJS require stack also drops the internal anchor.

Table 2: Interception position for the Web and Desktop profiles

profileDirectoryInterception layerDifference from web
web$DSH_HOME/profiles/web$DSH_HOME/profiles/node_modulesBaseline
desktop$DSH_HOME/profiles/desktop$DSH_HOME/profiles/node_modulesNone. Same tree, same layer; the Desktop Host runs the same resolver in Node mode
Out-of-tree profileAny directory loadProfileDirectory acceptsThe first ancestor node_modules above that directorySame rule; no product currently uses an out-of-tree profile, only unit tests cover it

Table 3: Ancestor chain when a plugin is linked outside the tree

The profile links <profile>/node_modules/my-plugin to the plugin's real directory R. R's manifest declares @deepseek-ai/dsh-tools as a peer and installs it as a devDependency, and declares zod as a dependency.

Import source → targetHook participatesResult
profile → linked pluginParticipates up to ②Node follows the symlink at ②; the plugin loads by its real path
Linked plugin → zodParticipates; zod is not occupied at R/node_modulesR/node_modules/zod, the developer's installed version
Linked plugin → @deepseek-ai/dsh-tools (peer)Participates; the name is occupied at R/node_modulesThe running dsh's copy; the devDependency copy in R/node_modules is not read
Linked plugin → a stateful dsh package declared only as a dependencyParticipates; the name is not occupiedIts own copy in R/node_modules, creating a second instance; the same mistake as installing the dsh package at ② inside the tree. Declare it as a peer instead
Linked plugin → undeclared package nameParticipates; R does not occupy the namePhysical contents of R/node_modules, then the same per-position rule along the real ancestor chain
Transitive dependency inside R → any package nameParticipatesEach position uses its own manifest's peers; nearer physical candidates precede later peer declarations
Link target R has no manifest → peer declared by an ancestorParticipatesSearch starts at R; an earlier physical candidate wins, otherwise the ancestor peer can select the runtime package even without physical node_modules there
Hoisted dependency whose real directory is outside every linked root → any package nameDoes not participateNative Node lookup from that dependency's real directory
Installation-scope package inside a broad linked directory → any package nameDoes not participate in linked interceptionNative lookup retains the host package's own dependencies
Any importer → require.resolve(pkg, { paths })Does not participateNative Node lookup using the supplied paths

Part 3: Developer integration guide

1. Normal installation

dsh plugin --profile <name> add <package | git spec | file:../local-checkout> forwards its argument to pnpm, which installs inside the profile directory with a hoisted layout. The profile's pnpm-workspace.yaml sets autoInstallPeers: false, so dsh packages a plugin declares as peers are not installed into the profile; the interception layer supplies the copy from the running installation, and the plugin and dsh share one module instance.

The plugin's own third-party dependencies are hoisted to $DSH_HOME/profiles/<name>/node_modules and found at ② on the ancestor chain. When a name collides with the installation closure, the nearest wins and the plugin uses the version it declared. The file: form copies the local checkout into the profile; resolution afterwards is identical to a registry installation.

2. Development mode: the plugin repository is outside the profile tree

npm link, or a bare directory path such as dsh plugin add ../my-plugin (which pnpm treats as link:), makes the profile entry a symlink to the plugin repository, which becomes a linked root. The plugin loads by its real path. When lookup reaches its manifest's peer position, the running installation supplies matching runtime entries, whether dsh was installed globally from npm, bundled with Desktop, or started from the source repository. The devDependency copy at that position serves the compiler and is not loaded by an ordinary request. A link to a src directory without a manifest can use a parent directory's peers through the same ancestor lookup.

Use the same manifest declarations as the harness packages: declare dsh packages whose instances must be shared with the host under both peerDependencies and devDependencies. The peer declaration occupies their positions at R/node_modules; the dev copies serve the compiler and standalone tests. Keep third-party dependencies and stateless dsh utilities such as @deepseek-ai/dsh-brand and @deepseek-ai/dsh-util-values under dependencies. After changing peerDependencies, new resolutions during a plugin reload use the new declaration; Node and Cordis still own the lifetime of already loaded modules. Equal package versions do not make copies from different directories share module-local state. Replacing only a scope tag with Symbol.for likewise cannot share scope parents, carrier keys, or registration tables; identity-bearing runtimes must resolve to the Host instance.

Two other layouts remain available: install @deepseek-ai/dsh in the plugin repository and run pnpm exec dsh --profile <name> there, or link dsh packages to a local source repository and start dsh from that repository. Both make the running dsh and the repository's copies identical, but linked plugins do not require either layout.

Alternatives considered

Let the runtime resolution act only as a fallback after the whole ancestor chain is exhausted. The ancestor chain would first read a leftover historical installation link at ③ and use it, which conflicts with "installation packages come from the running installation". The runtime resolution must occupy the positions of installation package names at ③.

Treat the runtime resolution as a whole layer inserted into the ancestor chain instead of occupying package directories at ③. The two differ in exactly one place: when CommonJS hits a runtime resolution package and the subpath is missing, the former would go back and read the old copy of the same name at ③, while the latter goes straight to ④ per Node's handling of a "found package". Occupying the package directory matches Node semantics and needs no extra check for ③.

Give installation closure entries absolute priority over nearer copies. The closure contains 281 third-party libraries. A plugin's private versions of zod, yaml, and others would be overridden by the versions in the installation closure, breaking the nearest-wins rule shared with Node. The supported installation flow no longer installs dsh peers into the profile, so overriding is not needed to guarantee a single instance.

Recognize and bypass symlinks in the .dsh-module-fallback layout at resolve time. This keeps dedicated logic in the lookup path for directories that are no longer produced, and every resolution pays for it. Removing those projections once at profile load reaches the same result: after a bundle is deselected while it stays installed, its projected plugin no longer shadows the same-named plugin the runtime resolution selects from another bundle.

Hide the whole physical directory that holds the interception layer. This conflicts with "all other resolution matches Node": non-installation packages placed in $DSH_HOME/profiles/node_modules would become invisible to every profile.

Let every runtime resolution entry occupy its package directory at R/node_modules. This has exactly the same form as ③, but the closure's hundreds of third-party libraries would shadow the plugin repository's own zod and yaml versions, unlike ① and ② preceding ③ inside the tree. Occupying only peers preserves the plugin's own third-party versions.

Use the nearest importer's manifest dependencies to decide which names resolve natively. That declaration does not identify a lookup position and cannot describe undeclared packages found at an ancestor. Per-position peer declarations add one eligibility rule without replacing Node's search order.

Read only the linked root's peers or require its own manifest. A link can name a src directory, and nearer packages and ancestor manifests have their own lookup positions. Fixing interception at R would skip these declarations or override a nearer physical candidate.

Freeze the peer set during runtime resolution construction. Every edit to peerDependencies would require restarting dsh. Reading at resolution time lets new lookups observe changes, at the cost of reading the visited manifests in development mode. Node's module caches still determine whether an already loaded request needs resolution again.

Intercept explicit CommonJS paths inside profiles. An explicit search list is the caller's choice. Passing it unchanged to Node keeps one clear exception instead of making individual items obey different policies.

Connect the ancestor chain above the linked root back to the profile's ② and ③. This follows the form of Node's --preserve-symlinks: R/node_modules remains the first native layer, so the devDependency copy still wins and the duplicate-instance problem remains.

Verification

  • The table-driven matrix in profile-resolution.spec.ts: importers are the profile root and a plugin inside the profile; package names are an installation entry, a bundle-only entry, and a name outside the table; every presence combination of the four layers ①②③④, with ② and ③ each in two forms, a real directory and a symlink pointing elsewhere; every cell asserts that ESM import, CommonJS require, require.resolve, and the packageDir metadata land in the same directory.
  • Linked-root cases in the same file link a profile package to an external repository with a same-named devDependency copy in its node_modules. Package names cover an installation entry declared as a peer, one declared as a dependency, the repository's own third-party dependency, and an undeclared name; importers cover the repository's own files and transitive dependencies inside it. The cases assert agreement across the four resolution forms, that the devDependency copy is not read, and that the next resolution observes a rewritten peerDependencies declaration.
  • The differential matrix constructs independent native, intercepted, and reference directories. Only the reference copy replaces eligible peer positions with links to the runtime package; Node resolves the resulting files. Single-package and pnpm monorepo cases compare entry, file, directory, import/require, import.meta.resolve, and explicit-path outcomes, including absent manifests, absent node_modules, malformed peer declarations, React, outside-scope helpers, and legacy subpath continuation. Assertions compare package selection, existing paths, error codes, and module identity; explicit paths use the pristine native reference.
  • Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without exports, #alias, explicit paths, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile.
  • The real CLI launch test covers src and lib launches with ordinary and npm-link layouts, plus a link to a manifest-free src directory whose parent declares a peer. It checks shared Tools/AgentLoop instances, native explicit-path selection of the developer's copy, real-path dependency resolution, and unchanged files and link targets. Source-mode CommonJS checks use fixture packages with existing JavaScript entries; real installation CommonJS entries require their build outputs.

Consequences

Gained: module resolution needs no disk projections; dsh installation packages selected at an interception layer come from the running installation, including peers declared by plugins linked outside the tree; other resolution retains Node's ancestor-chain and package-entry rules; the lookup path does not recognize historical projection directories.

Paid: third-party libraries in the installation closure still occupy package directories at ③; a profile plugin without a nearer copy gets the installation's version. A linked request selects a runtime package only at a matching peer position, and a nearer physical copy still wins. Developers must place declarations and dependencies accordingly. Runtime resolution does not enforce peer version ranges.

Only direct links from <profile>/node_modules become linked roots; dependencies whose real directories lie outside every recorded root use native Node. Peer requests do not return to the profile's ②: a name absent from the runtime table must be found on the real ancestor chain. Linked requests pay for current peer-manifest reads. Changing a link target still requires restart; module-cache invalidation and automatic watching are separate from this rule.

Explicit CommonJS paths, plugin-spawned processes, third-party Workers, and external tools do not receive runtime peer mappings. Consumers outside the hook cannot find installation packages through generated disk links. The resolver continues to depend on supported Node Internal interfaces.

中文

Problem

profile 从自己的包项目加载插件。dsh 本体包和 bundle 内嵌的依赖不在 profile 的依赖树上,Node 默认的 node_modules 查找从 profile 出发找不到它们。

解析规则需要能被开发者一句话复述:哪一段是普通 Node 行为,哪一段是 dsh 的干预,干预的范围到哪里为止。此前的运行时实现把 $DSH_HOME/profiles/node_modules 整层对所有包名隐藏,又为历史目录形状保留了专门的识别代码,规则无法用一句话说清,也无法判断某次解析结果是否符合预期。

Decision

普通模块请求保留 Node 的祖先 node_modules 顺序。profile 内,runtime resolution 条目占据 $DSH_HOME/profiles/node_modules 上对应的包位置。对于已记录的树外链接目录中的 importer,每个候选 D/node_modules 只应用一条规则:包名同时出现在 D/package.json 的 peerDependencies 和 runtime resolution 中,就由运行时包占据该位置;否则 Node 尝试该位置的物理包。更近的候选先于更高祖先的 peer 声明。显式 CommonJS paths 始终绕过拦截。其余解析行为由 Node 负责。不可变 generation 的构造、发布、Worker 继承与运行时载体由既有 Note 记录,本篇不复述。

一、解析规则

1. 主线:Node 的祖先 node_modules 链

importer 指发起 import 或 require 的那个模块文件。Node 从 importer 所在目录开始,沿祖先 node_modules 候选位置查到文件系统根,跳过多余的 node_modules/node_modules 位置。CommonJS 还查询 NODE_PATH 与全局目录,ESM 不查询。在 Node 的包入口与失败规则下,最近的匹配候选优先。

软链接按 Node 默认行为跟随到真实路径。一个模块一旦以真实路径加载,它后续 import 的主线就是真实路径的祖先链,与它被链接到哪里无关。

下面这条链是全文表格引用的编号。importer 为 $DSH_HOME/profiles/web/node_modules/my-plugin/index.js:

① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
② $DSH_HOME/profiles/web/node_modules
③ $DSH_HOME/profiles/node_modules
④ $DSH_HOME/node_modules
⑤ /node_modules

① 是插件私有依赖,② 是 profile 安装的包,③ 是拦截层,runtime resolution 占据其中有条目的包目录,④ 是 Harness home,⑤ 是文件系统根。CommonJS 随后保留 NODE_PATH 与全局目录的查询,不在这些额外位置应用 peer 拦截。

2. 拦截层:runtime resolution 占据 $DSH_HOME/profiles/node_modules/<包名>

拦截层是 importer 所属 profile 目录($DSH_HOME/profiles/<name>)之上的第一个祖先 node_modules。树内所有 profile 的这一层都是 $DSH_HOME/profiles/node_modules。

runtime resolution 中的每个包名,在这一层上占据 <拦截层>/<包名> 这个包目录的位置。对 @deepseek-ai/dsh-tools 这样的本体包名,③ 上的包目录就是运行中安装的那一份;磁盘上同名的旧链接或旧目录不再是链上的成员。对 runtime resolution 没有的包名,③ 上的包目录就是磁盘上的物理内容。

一次裸包名请求的顺序:主线先走完 profile 目录内部的各层(①②),有候选就交给 Node 在候选内解析并返回。走到 ③ 时,runtime resolution 有该包名的条目,Node 就在条目记录的包目录内解析;找到就返回,包内缺文件则按 Node 自身对"已找到包"的处理继续,CommonJS 从 ④ 继续逐层找子路径,ESM 直接报错。runtime resolution 没有条目,Node 就看 ③ 的物理目录,再到 ④。

Node 仍然负责 exports、imports、conditions、main、子路径、扩展名、缓存与错误码。被选中的包因 exports 拒绝某个子路径时报错终局,不会换另一个同名包。

拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 runtime resolution 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile node_modules 下目标位于 <profile>/.dsh-module-fallback/node_modules 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。

3. 链接目录:在每个祖先候选位置应用 peer 拦截

<profile>/node_modules/<包名> 链接到 profiles 树外的真实目录 R 时,R 是一个 linked root。R 不必包含 package.json 或 node_modules,可以是包目录、monorepo 中的单个包,也可以是 src 目录。R 之下的 importer 沿 Node 的真实祖先链查询。记录的根目录决定哪些 importer 参与拦截,不会把它们的查找限制在 R 内。

linked 拦截排除 installation 作用域包目录内的 importer,同时识别配置路径和真实路径。因此,链接整个 checkout 不会接管宿主自身的依赖查询。重叠的 linked root 共同描述参与范围:祖先链由 importer 决定,root 顺序和链接名称都不会选择另一套 peer 集合。

在每个候选 D/node_modules,当前 D/package.json 的 peer 声明命中且运行时表提供该包名时,就使用运行时包。这只占据该包的位置,即使 D/node_modules 在磁盘上不存在也成立。声明和运行时条目缺少任一项,Node 就尝试物理候选。缺少 manifest 不会终止查找。dependencies 与 devDependencies 不启用拦截,peer 的版本范围也不是额外的解析筛选条件。运行时表提供 React 等第三方 peer 时,同样适用这条规则。

更近的物理候选先于后续祖先的 peer 声明。选中包的 exports 拒绝请求时,Node 的错误是终局。旧式 CommonJS 子路径缺失可以继续到下一位置,再应用那个目录的 peer 规则;不会重试已被占据位置上的物理副本。每次新的 linked 解析读取所访问位置的 manifest,不缓存选中的路由。不可读的 manifest 不提供 peer,Node 保留其原生 manifest 诊断。这不清除 Node 缓存,也不自动重新加载已加载的模块。

runtime resolution 构造时扫描 <profile>/node_modules 的顶层与 @scope/* 链接。successor 可以在既有包映射和本地包名约束内新增或移除 linked root。移除无需重启:目录不再被任何 root 覆盖时,后续解析恢复原生查询。Generation 规则定义了发布、已有模块引用,以及跨越移除操作的重新链接检查。依赖的真实路径在所有已记录 linked root 之外时,即使插件链接到它,也不参与拦截。

4. 拦截层里有什么:runtime resolution 的扫描内容

runtime resolution 在 profile 启动时一次算出,由四部分组成。

  • installation 闭包:从当前运行的 dsh 包的 package.json 出发,沿 dependencies 与 peerDependencies 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。闭包有数百条,约一半在 @deepseek-ai/ 作用域,一半是第三方库。这些条目对所有 profile 生效。
  • bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。
  • 本地包名:profile 直接依赖中已经安装在 $DSH_HOME/profiles/<name>/node_modules 的包名。它们本来就在主线 ② 上,记录下来只为免去一次目录探测。
  • linked root:$DSH_HOME/profiles/<name>/node_modules 顶层与 @scope/* 里目标同时位于共享 profiles 树和当前 profile 目录之外的目录链接,记录链接名称与真实目录。目录自身的 manifest 可缺省。目标缺失或为文件的链接都排除。位于共享树外的应用自有 profile,不会把内部 pnpm store 链接登记为外部 root。

CLI 的安装锚点来自 import.meta.url,Node 已对它做过 realpath;Desktop Host 从其运行时目录拼出锚点,该目录不是软链接。两者的安装根目录都等于真实目录,bundle 发现与依赖遍历使用同一个位置。遍历中每一层依赖都以所属包的真实目录作为下一层的查找锚点,并记录声明它的 manifest 位置。命中后 Node 从这个声明位置解析,得到与该包自己内部 import 相同的结果。每个条目记录包名、包目录、版本、声明位置与作用域。已声明但未安装的依赖跳过;bundle 包根自身不成为条目。

5. hook 覆盖范围

hook 只在 importer 位于 $DSH_HOME/profiles/** 或某个 linked root 之下时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在这两处之外的一切请求,直接交给 Node。require.resolve(request, { paths }) 同样直接交给 Node,包括路径指向 profile 或链接目录的情况。

ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 runtime resolution。实现位于 packages/boot/app-boot/src/profile-resolution/resolver.ts,runtime resolution 的构造位于 packages/boot/app-boot/src/profile.ts。

6. 包目录与模块文件

packageDir('pkg', parentURL) 与 packageDir('pkg/sub', parentURL) 定位包目录,不加载代码,也不校验子路径。普通入口与文件请求仍由 Node 应用 exports、main、conditions 和文件存在性规则。因此,包目录查询可以成功,同时导入缺失或未导出的文件会失败。import.meta.resolve 可以为不存在的已导出文件返回 URL,但导入该 URL 仍然失败,这与原生 Node 一致。

二、预演

表 1:ESM 与 CommonJS 在各类请求下的主线与拦截

下表以本体包 @deepseek-ai/dsh-tools(runtime resolution 有条目)和第三方库 left-pad(runtime resolution 无条目)为例;pkg 泛指两者。

请求形态ESMCommonJS③ 上该包名的位置包内缺文件时
裸包名 pkg,包有 exports①② 有 pkg 目录则 Node 在其中按 exports 取入口Node 从同一候选选择 require condition有条目:runtime resolution 的包;无条目:物理目录已导出目标缺失时终局报错
裸包名 pkg,包无 exports入口取 main 或 index原生旧式入口查询同上ESM 终局报错;CommonJS 仅对原生查询未命中继续,声明的 main 无效时不继续
子路径 pkg/sub,包有 exports第一个找到的 pkg 决定;./sub 未导出则 ERR_PACKAGE_PATH_NOT_EXPORTED 终局同 ESM同上终局,不找别的副本
子路径 pkg/sub,包无 exports第一个找到的 pkg 内没有 sub 则 ERR_MODULE_NOT_FOUND 终局逐层尝试 <层>/pkg/sub:①②,然后 ③ 上该包名的位置(有条目即 runtime resolution 的包目录),然后 ④同上ESM 终局;CommonJS 继续下一层,即 ④
#alias 包内别名所属 manifest 的 imports 映射到裸包名后,按上面各行处理;映射到相对路径则直接交 Node同 ESM,conditions 取调用方传入或默认值同对应行同对应行
require.resolve(pkg, { paths })无此形式无论路径位于何处,都把整个请求及原始选项交给 Node不拦截,只看物理内容原生 Node 的继续查询与错误
包自引用(importer 所属包按自己的 name import)交 Node,保留原 importer同 ESM不参与不适用
相对 / 绝对 / URL / builtin交 Node交 Node不参与不适用

命中 runtime resolution 后,Node 报出的 ERR_MODULE_NOT_FOUND 与 ERR_PACKAGE_PATH_NOT_EXPORTED 会把内部的声明位置替换回原 importer;CommonJS 的 require stack 也去掉内部锚点。

表 2:Web 与 Desktop 两种 profile 的拦截位置

profile目录拦截层与 web 的差异
web$DSH_HOME/profiles/web$DSH_HOME/profiles/node_modules基准
desktop$DSH_HOME/profiles/desktop$DSH_HOME/profiles/node_modules无。同一棵树、同一层;Desktop 的 Host 以 Node 模式运行同一套 resolver
树外 profileloadProfileDirectory 接受的任意目录该目录之上的第一个祖先 node_modules规则相同;当前没有产品使用树外 profile,只有单元测试覆盖

表 3:插件被 link 到树外时的主线

插件 my-plugin 被 <profile>/node_modules/my-plugin 链接到真实目录 R;R 的 manifest 把 @deepseek-ai/dsh-tools 声明为 peer 并装成 devDependency,把 zod 声明为 dependency。

import 来源 → 目标hook 是否参与结果
profile → 链接的插件参与到 ② 为止② 的软链接被 Node 跟随,插件以真实路径加载
链接插件 → zod参与,R/node_modules 上 zod 未被占据R/node_modules/zod,开发者自己安装的版本
链接插件 → @deepseek-ai/dsh-tools(peer)参与,R/node_modules 上该名被占据运行中的 dsh 的那一份;R/node_modules 里的 devDependency 副本不被读到
链接插件 → 只声明为 dependency 的有状态 dsh 包参与,未被占据R/node_modules 里自己的副本,产生第二个实例;与树内把 dsh 包装进 ② 的错误相同,改为 peer 即可
链接插件 → 未声明的包名参与,R 不占据该包名R/node_modules 的物理内容,再沿真实祖先链逐位置应用同一规则
R 内传递依赖 → 任何包名参与每个位置使用自己的 manifest 的 peer;更近的物理候选先于后续 peer 声明
link 目标 R 无 manifest → 祖先声明的 peer参与从 R 开始查;更近的物理候选优先,否则祖先 peer 可以选中运行时包,即使那里没有物理 node_modules
真实目录在所有 linked root 外的提升依赖 → 任何包名不参与从该依赖真实目录进行原生 Node 查询
位于宽链接目录内的 installation 作用域包 → 任何包名不参与 linked 拦截原生查询保留宿主包自身的依赖
任意 importer → require.resolve(pkg, { paths })不参与使用指定路径进行原生 Node 查询

三、开发者接入指引

1. 正常安装

dsh plugin --profile <name> add <包名 | git spec | file:../本地检出> 把参数转发给 pnpm,在 profile 目录内以 hoisted 布局安装。profile 的 pnpm-workspace.yaml 设置 autoInstallPeers: false,插件声明为 peer 的 dsh 包不会被装进 profile,由拦截层提供当前运行安装的那一份,插件与 dsh 共用同一个模块实例。

插件自己的第三方依赖被 hoist 到 $DSH_HOME/profiles/<name>/node_modules,按主线 ② 找到。与 installation 闭包同名时最近者优先,插件使用自己声明的版本。file: 形式把本地检出复制进 profile,随后的解析与注册表安装完全相同。

2. 开发模式:插件仓库在 profile 树外

npm link,或 dsh plugin add ../my-plugin 这类裸目录路径(pnpm 按 link: 处理),会让 profile 里的条目成为指向插件仓库的软链接,仓库目录成为 linked root。插件以真实路径加载。查找到其 manifest 的 peer 位置时,匹配的运行时条目由当前运行的安装提供,无论 dsh 来自 npm 全局安装、Desktop 内置还是源码仓启动。该位置的 devDependency 副本服务编译器,普通请求不会加载它。link 指向无 manifest 的 src 目录时,也可以通过同一条祖先查询使用父目录的 peer。

manifest 的写法与 harness 自身的包相同:需要与宿主共享实例的 dsh 包同时声明在 peerDependencies 与 devDependencies,peer 让 R/node_modules 上的位置被占据,dev 副本给编译器和独立测试使用;第三方依赖以及 @deepseek-ai/dsh-brand、@deepseek-ai/dsh-util-values 这类无状态 dsh 工具包放在 dependencies。修改 peerDependencies 后,重新加载插件时的新解析会使用新声明;已加载模块的生命周期仍由 Node 和 Cordis 管理。版本相同也不会让不同目录中的副本共享模块局部状态。仅把 scope 标记改成 Symbol.for 同样无法共享 scope 父关系、carrier key 或注册表;承载身份的运行时必须解析到 Host 实例。

另两种可用布局:插件仓库自装 @deepseek-ai/dsh 并从仓库里启动 pnpm exec dsh --profile <name>;或把 dsh 包 link 到本机源码仓并从源码仓启动。两者让运行中的 dsh 与仓库里的副本本来就是同一份,但 linked 插件不要求使用这两种布局。

Alternatives considered

让 runtime resolution 只在主线全部走完后兜底。 主线会先在 ③ 读到历史残留的本体链接并使用它,与"本体以当前运行的安装为准"冲突。runtime resolution 必须占据 ③ 上本体包名的位置。

把 runtime resolution 当作插入主线的一整层,而不是占据 ③ 上的包目录。 两者只在一处不同:CommonJS 命中 runtime resolution 的包后子路径缺失时,前者会再回头读 ③ 上同名的旧副本,后者按 Node 对"已找到的包"的处理直接到 ④。占据包目录与 Node 语义一致,也不需要为 ③ 保留任何额外判断。

让 installation 闭包条目绝对优先于更近的副本。 闭包含 281 个第三方库。插件私有的 zod、yaml 等版本会被安装闭包里的版本覆盖,破坏与 Node 一致的最近者优先。受支持的安装流程已经不把 dsh 的 peer 装进 profile,不需要再用覆盖来保证单实例。

在解析时识别 .dsh-module-fallback 形状的软链接并绕过。 这会为不再产生的目录在查找路径里保留专门逻辑,每次解析都要付出判断。在 profile 加载时一次性删除这些投影能得到同样的结果:一个 bundle 被停用但仍装着时,它投影出来的插件不再遮住 runtime resolution 从另一个 bundle 选出的同名插件。

把拦截层所在的物理目录整层隐藏。 与"其余解析与 Node 一致"冲突:放在 $DSH_HOME/profiles/node_modules 的非本体包会对所有 profile 不可见。

让 runtime resolution 的全部条目占据 R/node_modules 上的包目录。 与 ③ 完全同形,但闭包里的几百个第三方库会遮住插件仓库自己安装的 zod、yaml 版本,与树内 ①② 赢 ③ 相反。只占据 peer 保住了「插件自己的第三方版本优先」。

按 importer 最近的 manifest 的 dependencies 决定哪些包名走原生。 这类声明不标识查询位置,也无法描述在祖先找到的未声明包。逐位置的 peer 声明只增加一条资格规则,不替换 Node 的查询顺序。

只读 linked root 的 peer,或要求它自身有 manifest。 link 可以指向 src 目录,更近的包与祖先 manifest 都有各自的查询位置。把拦截固定在 R 会漏掉这些声明,或覆盖更近的物理候选。

在 runtime resolution 构造时固定 peer 集合。 开发者每次修改 peerDependencies 都得重启 dsh。解析时读取让新查询看到变动,代价是开发模式下要读取所访问的 manifest。已加载的请求是否需要再次解析,仍由 Node 的模块缓存决定。

拦截指向 profile 内的显式 CommonJS paths。 显式搜索列表是调用方的选择。原样交给 Node 能保留一个清晰的例外,而不是让各个路径项遵循不同策略。

把 linked root 之上的主线接回 profile 的 ②③。 这是 Node --preserve-symlinks 的形式,R/node_modules 仍是原生的第一层,devDependency 副本照样先被找到,单实例问题原样保留。

Verification

  • profile-resolution.spec.ts 中的表驱动矩阵:importer 取 profile 根与 profile 内插件,包名取 installation 条目、bundle-only 条目与表外包名,①②③④ 四层的每一种有无组合,②③ 各含真目录与指向别处的软链接两种形态;每格断言 ESM import、CommonJS require、require.resolve 与 packageDir 元数据落在同一个目录。
  • 同一文件内的 linked root 用例:<profile>/node_modules 的软链接指向树外仓库,仓库 node_modules 里放同名 devDependency 副本;包名取声明为 peer 的 installation 条目、声明为 dependency 的 installation 条目、仓库自己的第三方 dependency、未声明的包名,importer 取仓库自身文件与仓库内传递依赖;断言四种解析方式一致、devDependency 副本不被读到,以及改写 peerDependencies 后下一次解析立即按新声明进行。
  • 差分矩阵构造独立的原生、拦截与参考目录。只有参考副本把合格 peer 位置替换为运行时包的链接,再让 Node 解析文件。单包与 pnpm monorepo 用例对比入口、文件、目录、import/require、import.meta.resolve 与显式路径的结果,覆盖 manifest 缺失、node_modules 缺失、错误 peer 声明、React、作用域外 helper 和旧式子路径继续查询。断言比较选包、现存路径、错误码与模块实例;显式 paths 使用未投射的原生参考。
  • 同一文件内的专项用例覆盖表 1 各行:有无 exports 的裸包名与子路径、#alias、显式 paths、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。
  • CLI 真启动测试覆盖 src 与 lib 启动、普通与 npm-link 布局,以及指向无 manifest 的 src 目录且其父目录声明 peer 的链接。测试检查 Tools/AgentLoop 共享实例、原生显式路径选中开发者副本、依赖沿真实路径解析,以及文件和链接目标保持不变。源码模式的 CommonJS 检查使用具备现存 JavaScript 入口的 fixture 包;安装中真实的 CommonJS 入口需要相应构建产物。

Consequences

买到的:模块解析无需磁盘投影;命中拦截层的 dsh 本体包来自当前运行的安装,link 到树外的插件声明的 peer 也适用;其余解析保留 Node 的祖先链和包入口规则;解析路径不识别历史投影目录。

付出的:installation 闭包中的第三方库仍占据 ③ 上的包目录;profile 插件没有更近副本时会使用安装中的版本。linked 请求只在匹配的 peer 位置选中运行时包,更近的物理副本仍优先。开发者需要相应安排声明与依赖位置。runtime resolution 不校验 peer 版本范围。

只有 <profile>/node_modules 的直接链接成为 linked root;依赖真实目录在所有已记录根之外时使用原生 Node。peer 请求不会回到 profile 的 ②:运行时表不提供的包名必须能在真实祖先链上找到。linked 请求承担实时读取 peer manifest 的成本。同一链接换目标仍需重启;模块缓存失效与自动监视不属于这条规则。

显式 CommonJS paths、插件自行启动的子进程、第三方 Worker 和外部工具不会获得运行时 peer 映射。不经过 hook 的消费者无法通过生成的磁盘链接找到本体包。resolver 继续依赖受支持的 Node Internal 接口。