Kaynağa Gözat

Merge pull request #4471 from deepseek-harness/worktree-resolutionmode

feat(cli): use runtime resolution for plain Node launches
imccyu 1 hafta önce
ebeveyn
işleme
8dfc6fe1ec

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
-2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5
-2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f
+2026-09-09-profile-resolution-generations.md: ad6f05ba88b25ea52da97834cf4638c8feb961ba
+2026-09-09-profile-resolution-generations.zh.md: ffa9abc5a6a04bc9553878946fa3fb422328312c

+ 7 - 7
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md

@@ -6,23 +6,23 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
 
 ## Problem
 
-A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
+A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
 
 The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
 
 ## Decision
 
-Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to link mode and preserves the existing materialized lookup behavior. Internal callers and tests can select runtime mode, which installs the generation into Node's ESM and CommonJS resolvers, or dual mode, which materializes and verifies the same generation. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
+Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to runtime mode, which installs the generation into Node's ESM and CommonJS resolvers without materializing fallback links. Plain Node callers and tests can explicitly select link mode to materialize the generation or dual mode to materialize and verify it. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
 
 ### One selection algorithm
 
-The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
+The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects runtime. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
 
 The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
 
 Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings.
 
-The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. The launcher uses it by default. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison.
+The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. Named profile launches use it only in explicit link or dual mode. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison.
 
 ### Immutable generations
 
@@ -74,11 +74,11 @@ Legacy disk state remains available to link-only launches, old processes, and ro
 
 Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
 
-The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly.
+The `dsh` launcher selects runtime mode when an ordinary Node caller omits `resolutionMode`. A pkg executable always selects runtime mode, and the Electron Host explicitly selects runtime mode in both development and packaged builds before any profile row mounts. Plain Node tests and low-level embedders can explicitly select link, dual, or runtime.
 
 Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
 
-Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links.
+Pkg and Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Their runtime resolvers do not create, update, or remove legacy resolution links.
 
 ### Performance and verification
 
@@ -115,4 +115,4 @@ Behavior tests compare the runtime generation with the disk materializer over th
 
 ## Consequences
 
-Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
+Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Runtime is the ordinary Node launcher default, link and dual remain explicit comparison options, and pkg plus Electron carriers force runtime resolution without the resolver retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.

+ 7 - 7
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md

@@ -6,23 +6,23 @@ Status: implemented
 
 ## Problem
 
-profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
+profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
 
 运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
 
 ## Decision
 
-profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 link 模式,保留现有的物化查找行为。内部调用方和测试可以选择 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以选择 dual 模式,同时物化并校验同一份 generation。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
+profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS 解析器,不物化 fallback 链接。普通 Node 调用方和测试可以显式选择 link 模式以物化 generation,或选择 dual 模式以物化并校验它。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
 
 ### 唯一选包算法
 
-包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
+包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 runtime。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
 
 安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
 
 profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。
 
-旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。launcher 默认调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。
+旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。具名 profile 启动只在显式 link 或 dual 模式下调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。
 
 ### 不可变 generation
 
@@ -74,11 +74,11 @@ runtime-only 启动流程不创建、更新或退休 symlink 和代理包。reso
 
 link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
 
-普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。
+普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 runtime 模式。pkg 可执行文件始终选择 runtime,Electron Host 在开发与打包构建中也会在挂载任何 profile 条目前显式选择 runtime。普通 Node 测试与底层嵌入方可以显式选择 link、dual 或 runtime。
 
 runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
 
-pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。
+pkg 与 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。它们的运行时解析器不会创建、更新或删除旧解析链接。
 
 ### 性能与验证
 
@@ -115,4 +115,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
 
 ## Consequences
 
-runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
+runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。runtime 是普通 Node launcher 的默认值,link 与 dual 保留为显式对比选项;pkg 与 Electron 载体强制使用 runtime,解析器不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 68500e54372d16a9ead8e548eed5a7e4836be3fb
-README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e
+README.md: 46c6a66336839db443a825c66ebfd71dcce398af
+README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434

+ 1 - 1
apps/cli/README.md

@@ -55,6 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
 
-The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
+The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
 
 The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

+ 1 - 1
apps/cli/README.zh.md

@@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
 
-`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
+`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
 
 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 2 - 2
apps/cli/reference/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: 6429b2872ec393304a7d3a76afd8194cb74d7698
-README.zh.md: c9d9bee8ad84e3fb0c3f4d82b295c743f5a5ad99
+README.md: 9ff0dce30f6de58c7b84f54186f7780900b319fc
+README.zh.md: e1ed9a9165b993cb4ea8a82af6641d5befd77a07

+ 1 - 1
apps/cli/reference/README.md

@@ -8,7 +8,7 @@ This reference defines the profile, plugin-management, and config-dump command m
 
 `dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
 
-Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
+Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable generation into Node's runtime resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution.
 
 The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
 

+ 1 - 1
apps/cli/reference/README.zh.md

@@ -10,7 +10,7 @@
 
 `dsh <name>` 是 `dsh --profile <name>` 的简写,启动位于 `$DSH_HOME/profiles/<name>` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
 
-组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
+组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 generation 安装到 Node 的运行时解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。
 
 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
 

+ 3 - 2
apps/cli/src/profile-boot.ts

@@ -191,6 +191,7 @@ interface ComposedProfile {
  * then the telemetry switch.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
+ * @param resolutionMode - runtime lookup, disk links, or dual verification of both.
  * @param fromDefaultProfile - shipped template for a missing named profile.
  * @param resolvedProfile - application-owned profile and installation.
  * @returns the profile and its patch layers.
@@ -237,7 +238,7 @@ export interface RunProfileOptions {
   args: readonly string[]
   /** Application-owned package runtime, scoped to plugin package operations. */
   packageManager?: ProfileContext['packageManager']
-  /** Module fallback backend; pkg executables always use runtime resolution. */
+  /** Module fallback backend; defaults to runtime. Plain Node callers may override it; pkg executables always use runtime. */
   resolutionMode?: ProfileResolutionMode
 }
 
@@ -259,7 +260,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   )
 
   const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
-  const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
+  const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'runtime'
   const app: { current?: Context } = {}
   let disposal: Promise<void> | undefined
   const dispose = (): Promise<void> => disposal ??= (async () => {

+ 1 - 1
apps/cli/tests/built-bin.e2e.ts

@@ -984,7 +984,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     try {
       await waitForFile(fixture.ready)
       expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
-      expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true)
+      expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(false)
       requestProfileShutdown(child, fixture)
       expect((await child).exitCode).toBe(0)
     } finally {

+ 18 - 17
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -5,11 +5,15 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
 import { zstdDecompress } from 'node:zlib'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
 
-const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url))
 const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
+const launch = resolveExampleLaunch({
+  srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
+  mode: 'lib',
+})
 const decompress = promisify(zstdDecompress)
 
 /** Frame one text or tool response from the local Messages endpoint. */
@@ -86,16 +90,15 @@ describe('Python SDK dsh profile keyless smoke', () => {
     if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
     // The line-predicate protocol driving below is the genuinely custom part;
     // execa owns spawn, the deadline, and exit settlement around it.
-    const child = execa(process.execPath, [
-      '--import',
-      'tsx/esm',
-      binScript,
+    const child = execa(launch.command, [
+      ...launch.args,
       '--profile',
       'sdk',
       ...(editorEnabled ? ['--patch', editorPatch] : []),
     ], {
       cwd: repoRoot,
       env: {
+        ...launch.env,
         DSH_HOME: join(root, '.dsh'),
         DSH_PERMISSION_MODE: 'danger-full-access',
         DSH_TELEMETRY_DISABLED: '1',
@@ -236,16 +239,15 @@ describe('Python SDK dsh profile keyless smoke', () => {
     await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
     const address = modelServer.address()
     if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
-    const child = execa(process.execPath, [
-      '--import',
-      'tsx/esm',
-      binScript,
+    const child = execa(launch.command, [
+      ...launch.args,
       '--profile',
       'sdk-minimal',
       ...(editorEnabled ? ['--patch', editorPatch] : []),
     ], {
       cwd: repoRoot,
       env: {
+        ...launch.env,
         DSH_HOME: join(root, '.dsh'),
         DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.',
         DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
@@ -285,7 +287,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
         const event = params?.event as Record<string, unknown> | undefined
         return params?.sessionId === 'minimal' && event?.type === 'turn/end'
       }, () => stderr)
-      expect(turnEnd).toMatchObject({
+      expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({
         params: { event: { data: { reason: { kind: 'completed' } } } },
       })
 
@@ -341,11 +343,11 @@ describe('Python SDK dsh profile keyless smoke', () => {
     await mkdir(home)
     if (blocked) await writeFile(join(home, 'logs'), 'blocked')
     await writeFile(patch, '- id: agent-loop\n  config:\n    maxParallelToolCalls: 0\n')
-    const child = execa(process.execPath, [
-      '--import', 'tsx/esm', binScript, '--profile', 'sdk', '--patch', patch,
+    const child = execa(launch.command, [
+      ...launch.args, '--profile', 'sdk', '--patch', patch,
     ], {
       cwd: repoRoot,
-      env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
+      env: { ...launch.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
       stdin: 'pipe',
       stripFinalNewline: false,
       timeout: 25_000,
@@ -378,15 +380,14 @@ describe('Python SDK dsh profile keyless smoke', () => {
   it('rejects an invalid max-token success env value', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
     try {
-      const { exitCode, stdout, stderr } = await execa(process.execPath, [
-        '--import',
-        'tsx/esm',
-        binScript,
+      const { exitCode, stdout, stderr } = await execa(launch.command, [
+        ...launch.args,
         '--profile',
         'sdk',
       ], {
         cwd: repoRoot,
         env: {
+          ...launch.env,
           DSH_HOME: join(root, '.dsh'),
           DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
           DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',

+ 57 - 16
apps/cli/tests/resolved-profile-boot.spec.ts

@@ -1,32 +1,44 @@
 /** Application-owned profiles share the named profile launch lifecycle. */
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
-import { boot, composeEntries, healIsolatedProfileModuleFallback, type Profile } from '@deepseek-ai/dsh-app-boot'
+import {
+  boot, composeEntries, createProfileResolutionGeneration, healIsolatedProfileModuleFallback,
+  PluginPackages, type Profile,
+} from '@deepseek-ai/dsh-app-boot'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { runProfile } from '../src/profile-boot.ts'
 
-vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({
-  ...await importOriginal<typeof import('@deepseek-ai/dsh-app-boot')>(),
-  boot: vi.fn(),
-  healIsolatedProfileModuleFallback: vi.fn(),
-  installFailLoud: vi.fn(),
-}))
+vi.mock('@deepseek-ai/dsh-app-boot', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@deepseek-ai/dsh-app-boot')>()
+  return {
+    ...actual,
+    boot: vi.fn(),
+    createProfileResolutionGeneration: vi.fn(actual.createProfileResolutionGeneration),
+    healIsolatedProfileModuleFallback: vi.fn(actual.healIsolatedProfileModuleFallback),
+    installFailLoud: vi.fn(),
+  }
+})
 vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() }))
 
 const homes: string[] = []
 afterEach(() => {
   vi.restoreAllMocks()
   vi.unstubAllEnvs()
-  vi.clearAllMocks()
+  vi.resetAllMocks()
   for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
 })
 
 describe('runProfile with an application-owned profile', () => {
-  it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
+  it.each(
+    (['link', 'runtime'] as const).flatMap(resolutionMode =>
+      (['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)
+        .map(stage => ({ resolutionMode, stage }))),
+  )('releases startup resources after a $stage failure in $resolutionMode mode', async ({ resolutionMode, stage }) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
     homes.push(home)
     mkdirSync(join(home, 'runtime'))
@@ -50,7 +62,7 @@ describe('runProfile with an application-owned profile', () => {
       await setup?.(ctx)
       throw failure
     })
-    if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure })
+    if (stage === 'composition') vi.mocked(createProfileResolutionGeneration).mockRejectedValueOnce(failure)
     const profile: Profile = {
       name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
       patches: [], layers: [],
@@ -58,6 +70,7 @@ describe('runProfile with an application-owned profile', () => {
     try {
       const application = runProfile({
         environment: createLaunchEnvironmentSnapshot([]), profile: 'desktop', patchFiles: [], args: ['--no-open'],
+        resolutionMode,
         resolvedProfile: { profile, installAnchor: join(home, 'runtime/package.json') },
       })
       if (stage === 'both-cleanups') {
@@ -70,23 +83,36 @@ describe('runProfile with an application-owned profile', () => {
         await expect(application).rejects.toBe(failure)
       }
       expect(disposeProxy).toHaveBeenCalledOnce()
+      expect(boot).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1)
       expect(dispose).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1)
     } finally {
       await ctx.fiber.dispose()
     }
   })
 
-  it.each(['link', 'runtime'] as const)('uses shared layers, %s resolution, and shutdown', async (resolutionMode) => {
+  it.each([
+    { selection: 'default', options: {}, mode: 'runtime' },
+    { selection: 'link', options: { resolutionMode: 'link' }, mode: 'link' },
+    { selection: 'dual', options: { resolutionMode: 'dual' }, mode: 'dual' },
+    { selection: 'runtime', options: { resolutionMode: 'runtime' }, mode: 'runtime' },
+  ] as const)('uses shared layers, $selection resolution, and shutdown', async ({ options, mode }) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
     homes.push(home)
     mkdirSync(join(home, 'runtime'))
-    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
-    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
+    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0","exports":"./index.cjs"}')
+    writeFileSync(join(home, 'runtime/index.cjs'), 'module.exports = "installation"\n')
+    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0","dependencies":{"test-local":"*"}}')
+    const localPackageDir = join(home, 'node_modules/test-local')
+    mkdirSync(localPackageDir, { recursive: true })
+    const localManifest = '{"name":"test-local","version":"1.0.0","exports":"./index.cjs"}'
+    writeFileSync(join(localPackageDir, 'package.json'), localManifest)
+    writeFileSync(join(localPackageDir, 'index.cjs'), 'module.exports = "profile"\n')
     vi.stubEnv('DSH_HOME', home)
     vi.stubEnv('DSH_TELEMETRY_DISABLED', '1')
     vi.spyOn(process, 'on').mockReturnValue(process)
     const oldExitCode = process.exitCode
     const ctx = new Context()
+    const plugin = vi.spyOn(ctx, 'plugin')
     // The real context supplies services; this test substitutes tree mounting and filesystem watchers.
     ctx.provide('loader', { create: vi.fn() })
     ctx.provide('hmr', {})
@@ -119,15 +145,30 @@ describe('runProfile with an application-owned profile', () => {
     const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
     try {
       const { shutdown } = await runProfile({
-        environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode,
+        environment, profile: 'desktop', resolvedProfile: runtime, ...options,
         patchFiles: [overlay], args: ['--port', '0', '--no-open'],
       })
       expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
-      if (resolutionMode === 'link') {
+      if (mode !== 'runtime') {
         expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
       } else {
         expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
       }
+      const generation = vi.mocked(createProfileResolutionGeneration).mock.settledResults
+        .find(result => result.type === 'fulfilled')?.value
+      expect(generation?.profileDir).toBe(home)
+      expect(plugin).toHaveBeenCalledWith(PluginPackages, mode === 'link' ? {} : {
+        generation,
+        behavior: mode === 'dual' ? 'verify' : 'enforce',
+      })
+      expect(existsSync(join(home, 'profiles/node_modules'))).toBe(false)
+      expect(existsSync(join(home, '.dsh-module-fallback'))).toBe(mode !== 'runtime')
+      expect(existsSync(join(home, 'node_modules/test-runtime'))).toBe(mode !== 'runtime')
+      expect(lstatSync(localPackageDir).isDirectory()).toBe(true)
+      expect(readFileSync(join(localPackageDir, 'package.json'), 'utf8')).toBe(localManifest)
+      const requireFromProfile = createRequire(join(home, 'package.json'))
+      expect(requireFromProfile('test-runtime')).toBe('installation')
+      expect(requireFromProfile('test-local')).toBe('profile')
       expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale')
       expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open'])
       const ready = vi.fn()

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de
-README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee
+README.md: fff15fb8200a776bd011e9f69815c5add75419b1
+README.zh.md: 9fd3fe71948200e520573cdf7958bfb03de83720

+ 1 - 1
apps/desktop/README.md

@@ -91,7 +91,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
 pnpm run start:desktop
 ```
 
-Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. Development and packaged profiles both use normal bundle resolution, including linked packages. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
+Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
 
 ## Package
 

+ 1 - 1
apps/desktop/README.zh.md

@@ -92,7 +92,7 @@ pnpm run dev:desktop
 pnpm run start:desktop
 ```
 
-Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。开发与打包 profile 都使用正常的 bundle 解析,包括链接包。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
+Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
 
 ## 打包
 

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 37aaf83e37fb5cb7e3df4bd6ed034ccd42103919
-architecture.zh.md: 084fa76a045a9ae744228e8e43900f174b26ae9e
+architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec
+architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394

+ 1 - 1
docs/architecture.md

@@ -50,7 +50,7 @@ The Python SDK follows the same application architecture. Its runtime wheel pack
 
 ## Desktop application
 
-The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared profile helpers initialize its files, reconcile installed bundles, and project missing dependencies without replacing pnpm-owned packages. CLI and Desktop share product data, while executable packages, activation choices, and lockfiles remain separate. The public CLI cannot manage Desktop’s profile.
+The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared profile helpers initialize its files, reconcile installed bundles, and resolve installation and bundle dependencies without replacing pnpm-owned packages. CLI and Desktop share product data, while executable packages, activation choices, and lockfiles remain separate. The public CLI cannot manage Desktop’s profile.
 
 Electron starts the private Desktop Host in Electron Node mode. The Host invokes the shared CLI profile runner and complete Web application. The window immediately loads packaged Web assets and waits for boot injections before activating client plugins in the same document. Web owns RPC and streams; the desktop carrier connects the local page to the authenticated Host. Node IPC carries boot injections, readiness, fatal errors, and shutdown. Desktop defaults to port `19387`; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
 

+ 1 - 1
docs/architecture.zh.md

@@ -50,7 +50,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
 
 ## 桌面应用
 
-[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 profile helper 初始化其文件、协调已安装 bundle,并补全缺失依赖而不替换 pnpm 拥有的包。CLI 与 Desktop 共享产品数据,可执行包、启用选择与锁文件保持独立。公开 CLI 不能管理 Desktop profile。
+[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 profile helper 初始化其文件、协调已安装 bundle,并解析安装与 bundle 的依赖而不替换 pnpm 拥有的包。CLI 与 Desktop 共享产品数据,可执行包、启用选择与锁文件保持独立。公开 CLI 不能管理 Desktop profile。
 
 Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用。窗口立即加载打包 Web 资源,等待启动注入后在同一文档中激活客户端插件。Web 负责 RPC 与流;桌面载体将本地页面连接到已认证的 Host。Node IPC 承载启动注入、就绪、致命错误与关闭。Desktop 默认端口为 `19387`,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
 

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 829456a4d11819ebcc77506ca3795fb8f8e996c7
-README.zh.md: adb5c870fb39792a2458358c1328b80a90ff965d
+README.md: 507445c13953d5b38fcc780f0c0e9adaac7a2d9d
+README.zh.md: 11ab4b9df5df5ca16addda7d58513fdd7869a1b0

+ 1 - 1
packages/boot/app-boot/README.md

@@ -58,7 +58,7 @@ The enabled `dsh-hmr` plugin watches the profile manifest and both user patch fi
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
-Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. The default link mode materializes the existing shared and profile-owned fallback links, so supported launch behavior stays unchanged. Internal callers and test harnesses can instead install the generation through Node's ESM and CommonJS resolvers in runtime mode, or materialize and verify the same generation in dual mode.
+Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. Runtime mode is the default: it installs the generation through Node's ESM and CommonJS resolvers without creating fallback links. Plain Node callers of `runProfile` may explicitly select link mode to materialize the generation, dual mode to materialize and verify it, or runtime mode. Packaged executables and the Electron Host always use runtime mode.
 
 `sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<timestamp>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (`-1`, `-2`, …) without changing it. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -58,7 +58,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
-挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。默认 link 模式会物化现有的共享 fallback 链接与 profile 自有 fallback 链接,因此受支持的启动行为保持不变。内部调用方和测试工具可以改用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以使用 dual 模式,同时物化并校验同一份 generation。
+挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。默认使用 runtime 模式,将 generation 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。普通 Node 中的 `runProfile` 调用方可以显式选择 link 模式以物化 generation,选择 dual 模式以物化并校验它,或选择 runtime 模式。打包可执行文件和 Electron Host 始终使用 runtime 模式。
 
 `sanitizeProfile(binName, profileDir, bundles)` 提供文件恢复,无需加载插件或解析 patch。Desktop 在原生致命错误恢复中调用它。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<timestamp>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时追加序号(`-1`、`-2`、……),时间戳保持不变。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
 

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,7 +1,7 @@
 {
   "AGENTS.md": 1950,
   "docs/AGENTS.md": 1320,
-  "docs/architecture.md": 2400,
+  "docs/architecture.md": 2410,
   "docs/cordis-primer.md": 600,
   "docs/defensive-patterns.md": 550,
   "docs/testing.md": 1350,