Просмотр исходного кода

refactor(desktop): share profile initialization and plugin management

07akioni 1 неделя назад
Родитель
Сommit
8c2fa73ceb
40 измененных файлов с 642 добавлено и 416 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 0 1
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 0 1
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  5. 6 6
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  6. 6 6
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  11. 6 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  12. 6 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  13. 11 69
      apps/cli/src/plugin.ts
  14. 44 0
      apps/cli/tests/built-bin.e2e.ts
  15. 2 2
      apps/desktop/README.i18n.yaml
  16. 8 5
      apps/desktop/README.md
  17. 8 5
      apps/desktop/README.zh.md
  18. 1 0
      apps/desktop/package.json
  19. 0 2
      apps/desktop/scripts/smoke-runtime.ts
  20. 22 81
      apps/desktop/src/profile-packages.ts
  21. 28 130
      apps/desktop/src/project-manager.ts
  22. 0 9
      apps/desktop/src/runtime-tree.ts
  23. 9 1
      apps/desktop/tests/plugin-pnpm.spec.ts
  24. 117 39
      apps/desktop/tests/profile-packages.spec.ts
  25. 25 37
      apps/desktop/tests/project-manager.spec.ts
  26. 10 2
      apps/desktop/tests/runtime-fixture.ts
  27. 2 2
      apps/desktop/tests/runtime-tree.spec.ts
  28. 1 0
      apps/desktop/tsconfig.json
  29. 2 2
      docs/architecture.i18n.yaml
  30. 2 2
      docs/architecture.md
  31. 2 2
      docs/architecture.zh.md
  32. 2 2
      packages/boot/app-boot/README.i18n.yaml
  33. 5 0
      packages/boot/app-boot/README.md
  34. 5 0
      packages/boot/app-boot/README.zh.md
  35. 6 0
      packages/boot/app-boot/src/index.ts
  36. 127 0
      packages/boot/app-boot/src/profile-plugins.ts
  37. 13 0
      packages/boot/app-boot/src/profile.ts
  38. 125 0
      packages/boot/app-boot/tests/profile-plugins.spec.ts
  39. 28 0
      packages/boot/app-boot/tests/profile.spec.ts
  40. 3 0
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: 0456784f864ad6ee89d1c8717488f954984fdce7
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 8259a5ac30353e001ccf99bba4b9f2151e8ea01b
+2026-08-25-electron-desktop-packaging-and-updates.md: 92dcd8b30ed43ed5a856e98649830402d7f0a5ff
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: b937baeb15e4058c65ca7d74dd02887a338491e0

+ 0 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md

@@ -48,7 +48,6 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
       pnpm-lock.yaml
       lock
       pnpm-workspace.yaml
-      desktop-runtime-state.json
       node_modules/
   sessions/
   storages/

+ 0 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -48,7 +48,6 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
       pnpm-lock.yaml
       lock
       pnpm-workspace.yaml
-      desktop-runtime-state.json
       node_modules/
   sessions/
   storages/

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: e36e67313b5319416dab0ce354d7e483d66d7900
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b0122ae97a79b33e26e653ba9a8b7f3756d1ff93
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 80c11592a16c50909cde072004f86d944f709fe8
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: ddb25909c8c8906f3763e549e9485f3ffc632c7b

+ 6 - 6
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md

@@ -26,11 +26,11 @@ The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.t
 
 Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
 
-Every first-party package in the dsh and private Host production closures is shared. The profile supplies missing packages through directory symlinks to those resources, or junctions on Windows; pnpm-installed packages take precedence. Links resolve to real host package directories under normal Node resolution. Host and plugin imports that resolve through these links share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
+The shared profile runner projects missing installation and selected-bundle dependencies within the Desktop profile. pnpm-installed packages take precedence. Links resolve to real package directories under normal Node resolution, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
 
-External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared host links remain available, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
+External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared fallback links supply missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
 
-The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates them.
+The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and module links to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
 
 ## Transactions and upgrades
 
@@ -40,16 +40,16 @@ Native canonical paths identify shared package directories. Windows launchers ca
 
 Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
 
-Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery. Recorded host links identify owned directories independently of package-operation completion.
+Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
 
 The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
 
 ## Alternatives considered
 
-Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory.
+Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
 
 - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
-- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
+- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Shared fallback supplies missing packages while pnpm-owned entries retain independent versions.
 - **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
 - **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
 - **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths.

+ 6 - 6
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md

@@ -26,11 +26,11 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
 
 包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
 
-dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 通过指向这些资源包的目录软链接提供缺失的包,在 Windows 上使用 junction;pnpm 安装的包优先。正常 Node 解析会把链接解析到实际宿主包目录。通过这些链接解析的宿主与插件导入对每个已解析导出共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
+共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
 
-外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或祖先目录依赖解析。这些检查重复了包管理器和加载器的职责,并拒绝 pnpm 支持的安装来源。共享宿主链接仍然可用,但插件可以解析到另一个已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立的桌面壳 UI 恢复。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。
+外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。共享补全链接提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
 
-profile manifest 分别记录pnpm 安装的依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建它们
+profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和模块链接交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份
 
 ## 事务与升级
 
@@ -40,16 +40,16 @@ profile manifest 分别记录pnpm 安装的依赖和已启用 bundle 列表。
 
 依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。
 
-Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和显式恢复的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立
+Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复
 
 [立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
 
 ## 考虑过的替代方案
 
-完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
 
 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
-- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
+- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
 - **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
 - **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.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-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: e91ee4c04cfab07e70e222f34e92ece7766bf5b4
-2026-09-09-desktop-in-place-profile.zh.md: 6846b0fbf8f0ae8c44659735817c4e66d7abdf9d
+2026-09-09-desktop-in-place-profile.md: 425145ad89f9697c2420544b0ce2e70759313ffc
+2026-09-09-desktop-in-place-profile.zh.md: 8b98ec1c2e426b0019115a9a19eb84db4b9caef8

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md

@@ -10,7 +10,7 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
-Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking and approved native builds remain. Compatible upgrades refresh links without copying plugin files.
+Desktop stops the Host and modifies the current profile directly. Shared app-boot cleanup detaches its own fallback links before package changes; the Host’s shared profile runner supplies required links on startup. Package locking and configured lifecycle scripts remain. Upgrades refresh module links without copying plugin files.
 
 Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md

@@ -10,7 +10,7 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
-Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。
+Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: f37976f9216eadb78673f00a22c1fae1c78bce11
-2026-09-10-desktop-web-wrapper.zh.md: 401a9e6fdb82ef1a6e415c3bdf5cbb7226458439
+2026-09-10-desktop-web-wrapper.md: 19365f6448e7cb5183d35922cff4dafdc493a93a
+2026-09-10-desktop-web-wrapper.zh.md: d78b96a772becb0d353504f7c32e0fb93811c4ff

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -16,6 +16,10 @@ The shared runner owns profile and Harness-home patches, proxy setup, telemetry
 
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled Node.js and pnpm, and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 
+App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
+
+Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
+
 This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
 
 ## Alternatives considered
@@ -24,6 +28,8 @@ This partially supersedes the private composition and portless transport in the
 
 **Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
 
+**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
+
 ## Consequences
 
 Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup, and Electron must load the ready URL instead of assuming a port or translating requests. The independent loading and recovery window remains available before the Web application starts.

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -16,6 +16,10 @@ Status: implemented
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 Node.js 和 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 
+App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
+
+共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
+
 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
 
 ## Alternatives considered
@@ -24,6 +28,8 @@ Status: implemented
 
 **合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
 
+**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
+
 ## Consequences
 
 Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动,Electron 必须加载就绪 URL,而不是假设端口或转换请求。独立加载与恢复窗口在 Web 应用启动前仍可用。

+ 11 - 69
apps/cli/src/plugin.ts

@@ -17,79 +17,14 @@ import {
   DEFAULT_PROFILE_BUNDLES,
   initProfile,
   PROFILE_TEMPLATES,
-  readProfileManifest,
-  resolveBundleDir,
+  readProfilePlugins,
+  reconcileProfilePlugins,
   resolveProfileDir,
-  writeProfileManifest,
-  type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
 import { INSTALL_ANCHOR } from './profile-boot.ts'
 
 const NAME = 'dsh'
 
-/**
- * Whether a resolved dependency exports a profile patch, i.e. is a bundle.
- * @param packageName - the dependency's package name.
- * @param profileDir - the profile directory (resolution anchor).
- * @returns true when the package manifest declares `dsh.bundle`.
- */
-function exportsPatch(packageName: string, profileDir: string): boolean {
-  let dir: string
-  try {
-    dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
-  } catch {
-    return false // pnpm reported success yet the package is unresolvable — treat as plain
-  }
-  const manifest = readProfileManifest(NAME, dir)
-  return manifest.dsh?.bundle?.patch !== undefined
-}
-
-/**
- * Reconcile `dsh.profile.bundles` against the installed state: pnpm has
- * already written the real installed names (so a git/path/tarball/alias spec
- * on the command line reconciles by its true package name) and materialized
- * the packages. A dependency that resolves to a `dsh.bundle`-declaring
- * package joins the layer stack (appended in dependency order); a
- * dependency-listed name that no longer does — removed, or the installed
- * version dropped the declaration — leaves it. In-box bundles from the
- * profile template are not dependencies and are never touched. Warns once
- * per newly-added bundle-less dependency (a plain library is fine; the
- * warning is orientation).
- */
-function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
-  const after = readProfileManifest(NAME, profileDir)
-  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
-  const dependencies = Object.keys(after.dependencies ?? {})
-  const plugins = after.dsh?.profile?.bundles ?? []
-  let changed = false
-  for (const packageName of dependencies) {
-    const isBundle = exportsPatch(packageName, profileDir)
-    if (isBundle && !plugins.includes(packageName)) {
-      plugins.push(packageName)
-      changed = true
-    } else if (!isBundle && !beforeDeps.has(packageName)) {
-      process.stderr.write(
-        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
-        + '(a later update that gains one activates it automatically)\n',
-      )
-    }
-  }
-  const dependencySet = new Set(dependencies)
-  for (const packageName of [...plugins]) {
-    // Only dependency-managed entries are subject to removal; template
-    // bundles (dsh-base and friends) are not dependencies.
-    const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
-    const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
-    if (wasDependency && !stillBundle) {
-      plugins.splice(plugins.indexOf(packageName), 1)
-      changed = true
-    }
-  }
-  if (!changed) return
-  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
-  writeProfileManifest(profileDir, after)
-}
-
 /**
  * Rewrite relative filesystem specs against the user's invoking directory.
  * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
@@ -128,7 +63,8 @@ export function runPlugin(profile: string, args: readonly string[]): number {
     )
     process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
   }
-  const before = readProfileManifest(NAME, dir)
+  const location = { binName: NAME, profileDir: dir, installAnchor: INSTALL_ANCHOR }
+  const before = readProfilePlugins(location)
   // Windows resolves pnpm through its .cmd shim, which spawn() refuses
   // without a shell since the CVE-2024-27980 hardening.
   const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
@@ -146,7 +82,13 @@ export function runPlugin(profile: string, args: readonly string[]): number {
   }
   const exitCode = result.status ?? 1
   if (exitCode === 0) {
-    reconcilePlugins(before, dir)
+    const result = reconcileProfilePlugins({ ...location, before, preserveDisabled: false })
+    for (const packageName of result.addedPlainDependencies) {
+      process.stderr.write(
+        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
+        + '(a later update that gains one activates it automatically)\n',
+      )
+    }
   } else {
     // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
     // one; the profile owns it, and the commonest failure here is pnpm ≥10

+ 44 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -970,6 +970,50 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 2 + 30_000)
 
+  it('reconciles a real pnpm alias and keeps ordinary dependencies outside the bundle list', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-alias-'))
+    try {
+      const bundle = join(home, 'bundle-source')
+      const library = join(home, 'library-source')
+      mkdirSync(bundle)
+      mkdirSync(library)
+      writeFileSync(join(bundle, 'package.json'), JSON.stringify({
+        name: 'original-bundle-name', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
+      }))
+      writeFileSync(join(bundle, 'cordis.patch.yml'), '[]\n')
+      writeFileSync(join(library, 'package.json'), JSON.stringify({ name: 'ordinary-library', version: '1.0.0' }))
+      const added = await runBuiltBin([
+        'plugin', '--profile', 'alias', 'add', `bundle-alias@file:${bundle}`, `file:${library}`,
+      ], { DSH_HOME: home }, home)
+      expect(added.code).toBe(0)
+      expect(added.stderr).toContain('ordinary-library declares no dsh.bundle — installed as a plain dependency, not a profile layer')
+      const manifestPath = join(home, 'profiles', 'alias', 'package.json')
+      const installed = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
+        dependencies: Record<string, string>
+        dsh: { profile: { bundles: string[] } }
+      }
+      expect(Object.keys(installed.dependencies).sort()).toEqual(['bundle-alias', 'ordinary-library'])
+      expect(installed.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'bundle-alias'])
+      installed.dsh.profile.bundles = ['@deepseek-ai/dsh-base']
+      writeFileSync(manifestPath, JSON.stringify(installed))
+      const refreshed = await runBuiltBin(['plugin', '--profile', 'alias', 'root'], { DSH_HOME: home }, home)
+      expect(refreshed.code).toBe(0)
+      expect(refreshed.stderr).not.toContain('declares no dsh.bundle')
+      const active = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+      expect(active.dsh.profile.bundles).toContain('bundle-alias')
+      const removed = await runBuiltBin(['plugin', '--profile', 'alias', 'remove', 'bundle-alias'], { DSH_HOME: home }, home)
+      expect(removed.code).toBe(0)
+      const remaining = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
+        dependencies: Record<string, string>
+        dsh: { profile: { bundles: string[] } }
+      }
+      expect(Object.keys(remaining.dependencies)).toEqual(['ordinary-library'])
+      expect(remaining.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
+    } finally {
+      rmSync(home, { recursive: true, force: true })
+    }
+  })
+
   it('activates a dependency that gained dsh.bundle in a later update', async () => {
     // Reconcile runs against the INSTALLED state on every successful pnpm
     // run, so `update` (not only `add`) activates a package whose newer

+ 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: ee1789c58d7d15946ebb06e1ecb148ededd652ea
-README.zh.md: 8ac973aa96b3c3a4e8c40b97f79bfc85ba7e7909
+README.md: 8976fd23a5ad77f35da0182d6fb5761e96af80c9
+README.zh.md: ef1689e30975d65690e04861763419c494c3b8e6

+ 8 - 5
apps/desktop/README.md

@@ -11,7 +11,7 @@ The desktop application is an Electron shell around the complete dsh Web applica
 | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
 | Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. |
 | Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
-| Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. |
+| Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
 | Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
 | Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
@@ -25,23 +25,27 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
 
 The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
+The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop supplies its native directory picker as a profile overlay.
+
 Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
 
 ### Runtime and plugin activation
 
 The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
-1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
-2. A compatible application upgrade refreshes shared links in the current profile without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
+1. The main window displays a local loading page before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
+2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 3. Changes to bundled Node, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
 5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 
+CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
+
 The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
 
 Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
 
-Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow pnpm’s configured build policy. Release preparation owns its separate build-time allowlist.
+Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 
@@ -196,7 +200,6 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
 
 ## Known limitations
 
-- The Web "Open In..." action is disabled in Desktop because its host plugin requires HTTP routes; Desktop does not provide a `webServer`.
 - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
 - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
 - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.

+ 8 - 5
apps/desktop/README.zh.md

@@ -11,7 +11,7 @@
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
 | 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。 |
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
-| 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
+| 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
 | 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
@@ -25,23 +25,27 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 
 
 本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权。
 
+产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 通过 profile overlay 提供原生目录选择器。
+
 Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
 
 ### 运行时与插件激活
 
 签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单
-2. 兼容的应用升级在当前 profile 中刷新共享链接,不检查插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
+1. 主窗口在 profile 准备或后端启动前显示本地加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接
+2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
 3. 内置 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
 5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
+CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
+
 加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。
 
 重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
 
-包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 pnpm 的构建配置。发布准备阶段拥有独立的构建许可列表。
+包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表。
 
 ## 开发
 
@@ -196,7 +200,6 @@ pnpm run prepare:desktop
 
 ## 已知限制
 
-- Desktop 禁用 Web 的「在本地应用中打开…」操作,因为其 Host 插件依赖 HTTP 路由,而 Desktop 不提供 `webServer`。
 - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。

+ 1 - 0
apps/desktop/package.json

@@ -34,6 +34,7 @@
   },
   "devDependencies": {
     "@aws-sdk/client-s3": "3.1067.0",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@electron/notarize": "2.5.0",
     "@types/js-yaml": "^4.0.9",

+ 0 - 2
apps/desktop/scripts/smoke-runtime.ts

@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { DesktopHostProcess } from '../src/host-process.ts'
 import { createPluginProfile } from '../src/project-manager.ts'
-import { linkDesktopHostPackages } from '../src/profile-packages.ts'
 import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
 
 /**
@@ -46,7 +45,6 @@ export function apply(ctx) {
     manifest.dsh.profile.bundles.push(pluginName)
     writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest))
     writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n  config:\n    host: 127.0.0.1\n    port: 0\n')
-    linkDesktopHostPackages(profile, root, runtime)
     const ready = await host.start()
     const login = await fetch(ready.url, { redirect: 'manual' })
     const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')

+ 22 - 81
apps/desktop/src/profile-packages.ts

@@ -1,31 +1,16 @@
-/** Desktop-owned host links and applied runtime identity. */
+/** One-time removal of package links recorded by Desktop profile management. */
 
-import { createHash } from 'node:crypto'
-import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { lstatSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs'
 import { dirname, isAbsolute, join, resolve } from 'node:path'
-import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 
-/** Applied runtime identity and the only links Desktop may replace. */
+/** Legacy Desktop package-link ownership file. */
 export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json'
 
-/** Durable ownership of a package-directory link. */
-export interface DesktopPackageLink {
+interface DesktopPackageLink {
   readonly name: string
   readonly target: string
 }
 
-/** Runtime identity and managed links; package preparation may still be pending. */
-export interface DesktopProfileState {
-  readonly schemaVersion: 1
-  readonly runtimeId: string
-  readonly version: string
-  readonly nodeVersion: string
-  readonly platform: string
-  readonly arch: string
-  readonly lockHash: string
-  readonly links: readonly DesktopPackageLink[]
-}
-
 const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
 
 function record(value: unknown): value is Record<string, unknown> {
@@ -40,76 +25,32 @@ function stat(path: string): ReturnType<typeof lstatSync> | undefined {
 }
 
 /**
- * Read profile state without interpreting an unpublished predecessor format.
+ * Remove recorded links still owned by Desktop and delete their legacy state file.
+ * Installed directories, changed links, and links beneath redirected package directories are retained.
  * @param profile - Desktop profile directory.
- * @returns Validated state, or undefined for an uninitialized profile.
  */
-export function readDesktopProfileState(profile: string): DesktopProfileState | undefined {
-  const path = join(profile, DESKTOP_PROFILE_STATE)
-  if (!existsSync(path)) return undefined
-  const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
-  if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string'
-    || !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string'
-    || typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string'
-    || typeof value.lockHash !== 'string' || !Array.isArray(value.links)) {
-    throw new Error('desktop profile: invalid runtime state')
-  }
+export function migrateDesktopProfileLinks(profile: string): void {
+  const statePath = join(profile, DESKTOP_PROFILE_STATE)
+  if (stat(statePath) === undefined) return
+  const value: unknown = JSON.parse(readFileSync(statePath, 'utf8'))
+  if (!record(value) || !Array.isArray(value.links)) throw new Error('desktop profile: invalid legacy package links')
   const links = value.links.map((link: unknown): DesktopPackageLink => {
     if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name)
       || typeof link.target !== 'string' || !isAbsolute(link.target)) {
-      throw new Error('desktop profile: invalid managed link')
+      throw new Error('desktop profile: invalid legacy package link')
     }
     return { name: link.name, target: link.target }
   })
-  if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link')
-  return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion,
-    platform: value.platform, arch: value.arch, lockHash: value.lockHash, links }
-}
-
-/**
- * Hash the plugin lockfile, including the empty-profile case.
- * @param profile - Desktop profile directory.
- * @returns Lockfile content identity.
- */
-export function desktopPluginLockHash(profile: string): string {
-  const lock = join(profile, 'pnpm-lock.yaml')
-  return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex')
-}
-
-/**
- * Remove only recorded host links, without following even broken targets.
- * @param profile - Desktop profile.
- */
-export function unlinkDesktopHostPackages(profile: string): void {
-  for (const link of readDesktopProfileState(profile)?.links ?? []) {
-    const path = join(profile, 'node_modules', link.name)
-    const entry = stat(path)
-    if (entry === undefined) continue
-    if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) {
-      continue
+  const modules = join(profile, 'node_modules')
+  if (stat(modules)?.isDirectory() === true) {
+    for (const link of links) {
+      const path = join(modules, link.name)
+      if (dirname(path) !== modules && stat(dirname(path))?.isDirectory() !== true) continue
+      if (stat(path)?.isSymbolicLink() === true
+        && resolve(dirname(path), readlinkSync(path)) === resolve(link.target)) {
+        unlinkSync(path)
+      }
     }
-    unlinkSync(path)
-  }
-}
-
-/**
- * Supply missing profile packages from this application's real package directories.
- * @param profile - Candidate profile.
- * @param root - Current immutable runtime directory.
- * @param runtime - Verified release descriptor.
- */
-export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void {
-  unlinkDesktopHostPackages(profile)
-  const links = runtime.sharedPackages
-    .filter(entry => stat(join(profile, 'node_modules', entry.name)) === undefined)
-    .map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) }))
-  for (const link of links) {
-    const path = join(profile, 'node_modules', link.name)
-    mkdirSync(dirname(path), { recursive: true })
-    symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir')
   }
-  const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version,
-    nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch,
-    lockHash: desktopPluginLockHash(profile), links }
-  writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 })
+  unlinkSync(statePath)
 }

+ 28 - 130
apps/desktop/src/project-manager.ts

@@ -11,7 +11,6 @@ import {
   closeSync,
   readFileSync,
   readdirSync,
-  realpathSync,
   unlinkSync,
   writeFileSync,
   writeSync,
@@ -25,11 +24,12 @@ import {
 import type { DesktopPaths } from './paths.ts'
 import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
-import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
+import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
-  desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
-  unlinkDesktopHostPackages,
-} from './profile-packages.ts'
+  initProfile, readProfileManifest, readProfilePlugins, reconcileProfilePlugins,
+  unlinkProfileModuleFallback, writeProfileBundles,
+} from '@deepseek-ai/dsh-app-boot'
+import { migrateDesktopProfileLinks } from './profile-packages.ts'
 
 /** Desktop plugin record derived from the installed profile. */
 export interface DesktopPluginRecord {
@@ -39,17 +39,6 @@ export interface DesktopPluginRecord {
   readonly enabled: boolean
 }
 
-/** Installed desktop project manifest slice. */
-interface DesktopProjectManifest {
-  readonly [key: string]: unknown
-  readonly dependencies: Record<string, string>
-  readonly dsh: {
-    readonly profile: {
-      readonly bundles: string[]
-    }
-  }
-}
-
 /** Exact executables the desktop shell bundles. */
 export interface DesktopRuntimeExecutables {
   readonly node: string
@@ -88,10 +77,6 @@ function writeJson(path: string, value: unknown): void {
   writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 })
 }
 
-function readJson(path: string): unknown {
-  return JSON.parse(readFileSync(path, 'utf8'))
-}
-
 function workspaceFile(overrides: Readonly<Record<string, string>> = {}): string {
   const entries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right))
   const overrideSection = entries.length === 0
@@ -114,54 +99,6 @@ function migrateProfileSettings(projectDir: string): void {
   }
 }
 
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
-function projectManifest(projectDir: string): DesktopProjectManifest {
-  const path = join(projectDir, 'package.json')
-  const value = readJson(path)
-  if (!isRecord(value)) throw new Error(`desktop project: ${path} must hold a JSON object`)
-  const manifest = value as { dependencies?: Record<string, string>; dsh?: { profile?: { bundles?: string[] } } }
-  return { ...value, dependencies: manifest.dependencies ?? {},
-    dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: manifest.dsh?.profile?.bundles ?? [] } } }
-}
-
-function installedManifest(projectDir: string, name: string): Record<string, unknown> | undefined {
-  let value: unknown
-  try {
-    value = readJson(join(projectDir, 'node_modules', name, 'package.json'))
-  } catch {
-    // Unreadable installed metadata must not prevent listing or removing a dependency.
-    return undefined
-  }
-  return isRecord(value) ? value : undefined
-}
-
-function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] {
-  const manifest = projectManifest(projectDir)
-  return Object.entries(manifest.dependencies).sort(([left], [right]) => left.localeCompare(right)).map(([name, spec]) => {
-    const installed = installedManifest(projectDir, name)
-    return { name, version: typeof installed?.version === 'string' ? installed.version : spec,
-      enabled: manifest.dsh.profile.bundles.includes(name) }
-  })
-}
-
-function writeProfileBundles(projectDir: string, bundles: readonly string[]): void {
-  const manifest = projectManifest(projectDir)
-  writeJson(join(projectDir, 'package.json'), {
-    ...manifest,
-    dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles } },
-  })
-}
-
-function exportsPatch(projectDir: string, name: string): boolean {
-  const manifest = installedManifest(projectDir, name)
-  const dsh = manifest?.dsh
-  const bundle = isRecord(dsh) ? dsh.bundle : undefined
-  return isRecord(bundle) && bundle.patch !== undefined
-}
-
 /** Desktop npm project manager with direct writes and no rollback. */
 export class DesktopProjectManager {
   private lockDescriptor: number | undefined
@@ -179,7 +116,9 @@ export class DesktopProjectManager {
   /** Read the active desktop plugin inventory. */
   listPlugins(): readonly DesktopPluginRecord[] {
     if (!existsSync(this.paths.profile)) return []
-    return pluginRecords(this.paths.profile)
+    return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies
+      .map(({ name, version, enabled }) => ({ name, version, enabled }))
+      .sort((left, right) => left.name.localeCompare(right.name))
   }
 
   /**
@@ -198,7 +137,6 @@ export class DesktopProjectManager {
         else unlinkSync(path)
       }
       createPluginProfile(this.paths.profile)
-      this.prepareProfile(this.paths.profile)
       await hooks.afterChange()
     })
   }
@@ -208,13 +146,6 @@ export class DesktopProjectManager {
     return this.currentRuntime().release.version
   }
 
-  /** Read the release most recently applied to the active profile. */
-  releaseVersion(): string {
-    const state = readDesktopProfileState(this.paths.profile)
-    if (state === undefined) throw new Error('desktop project: active profile has no runtime state')
-    return state.version
-  }
-
   /** @returns Whether application resources support profile recovery. */
   canRecoverProfile(): boolean {
     return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
@@ -230,27 +161,17 @@ export class DesktopProjectManager {
     return readDesktopRuntime(this.runtime.dsh)
   }
 
-  private prepareProfile(projectDir: string): void {
-    linkDesktopHostPackages(projectDir, this.runtime.dsh, this.currentRuntime())
+  private pluginLocation(profileDir: string) {
+    return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') }
   }
 
-  /** Read release metadata and reconcile its external profile without installing core packages. */
-  async applyRelease(): Promise<boolean> {
-    return this.withLock(() => {
-      const target = this.readRuntime()
-      this.descriptor = target
+  /** Load application metadata and initialize missing profile files without installing packages. */
+  async applyRelease(): Promise<void> {
+    await this.withLock(() => {
+      this.descriptor = this.readRuntime()
       migrateProfileSettings(this.paths.profile)
-      const previous = readDesktopProfileState(this.paths.profile)
-      if (previous?.runtimeId === desktopRuntimeId(target)
-        && previous.lockHash === desktopPluginLockHash(this.paths.profile)
-        && previous.links.every(link => existsSync(link.target)
-          && existsSync(join(this.paths.profile, 'node_modules', link.name))
-          && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) {
-        return false
-      }
-      if (!existsSync(join(this.paths.profile, 'package.json'))) createPluginProfile(this.paths.profile)
-      this.prepareProfile(this.paths.profile)
-      return true
+      migrateDesktopProfileLinks(this.paths.profile)
+      createPluginProfile(this.paths.profile)
     })
   }
 
@@ -261,52 +182,34 @@ export class DesktopProjectManager {
       if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
       await hooks.beforeChange()
       if (mutation.type === 'plugins-disable-all') {
-        const manifest = projectManifest(this.paths.profile)
-        writeJson(join(this.paths.profile, 'package.json'), {
-          ...manifest,
-          dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } },
-        })
-        this.prepareProfile(this.paths.profile)
-        await hooks.afterChange()
-        return
-      }
-      const packagesChanged = mutation.type !== 'plugin-toggle'
-      if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile)
-      try {
+        writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), DESKTOP_PROFILE_BUNDLES)
+      } else {
         await this.applyMutation(this.paths.profile, mutation)
-      } finally {
-        if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime())
       }
       await hooks.afterChange()
     })
   }
 
   private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
-    const before = projectManifest(projectDir)
-    const bundles = before.dsh.profile.bundles
+    const location = this.pluginLocation(projectDir)
+    const before = readProfilePlugins(location)
+    const bundles = before.manifest.dsh?.profile?.bundles ?? []
     switch (mutation.type) {
       case 'plugin-add':
       case 'plugin-update': {
-        const dependencies = new Set(Object.keys(before.dependencies))
-        const disabled = new Set([...dependencies].filter(name => !bundles.includes(name) && exportsPatch(projectDir, name)))
         const spec = mutation.type === 'plugin-add' ? mutation.spec : `${mutation.name}@${mutation.version}`
+        unlinkProfileModuleFallback(projectDir)
         await this.runPnpm(projectDir, ['add', '--', spec])
-        const after = projectManifest(projectDir)
-        const installed = new Set(Object.keys(after.dependencies))
-        const active = bundles.filter(name => !(dependencies.has(name) || installed.has(name))
-          || (installed.has(name) && exportsPatch(projectDir, name)))
-        for (const name of installed) {
-          if (!active.includes(name) && !disabled.has(name) && exportsPatch(projectDir, name)) active.push(name)
-        }
-        writeProfileBundles(projectDir, active)
+        reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
         return
       }
       case 'plugin-remove':
+        unlinkProfileModuleFallback(projectDir)
         await this.runPnpm(projectDir, ['remove', '--', mutation.name])
-        writeProfileBundles(projectDir, bundles.filter(name => name !== mutation.name))
+        reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
         return
       case 'plugin-toggle':
-        writeProfileBundles(projectDir, mutation.enabled
+        writeProfileBundles(projectDir, before.manifest, mutation.enabled
           ? bundles.includes(mutation.name) ? bundles : [...bundles, mutation.name]
           : bundles.filter(name => name !== mutation.name))
         return
@@ -419,7 +322,7 @@ export class DesktopProjectManager {
 export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void {
   mkdirSync(projectDir, { recursive: true, mode: 0o700 })
   const packageSet = verifyDesktopCorePackageSet(projectDir, release.version)
-  const manifest: DesktopProjectManifest = {
+  const manifest = {
     name: PROJECT_NAME,
     private: true,
     version: '0.0.0',
@@ -457,10 +360,5 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De
 
 /** Create the first external plugin profile without running a package manager. */
 export function createPluginProfile(projectDir: string): void {
-  mkdirSync(projectDir, { recursive: true, mode: 0o700 })
-  writeJson(join(projectDir, 'package.json'), {
-    name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {},
-    dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
-  } satisfies DesktopProjectManifest)
-  writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
+  initProfile(projectDir, DESKTOP_PROFILE_BUNDLES)
 }

+ 0 - 9
apps/desktop/src/runtime-tree.ts

@@ -211,12 +211,3 @@ export async function verifyDesktopRuntime(
   }
   return descriptor
 }
-
-/**
- * Identify exact runtime content independently of its installation path.
- * @param descriptor - Validated runtime metadata.
- * @returns SHA-256 runtime identity.
- */
-export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string {
-  return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex')
-}

+ 9 - 1
apps/desktop/tests/plugin-pnpm.spec.ts

@@ -7,6 +7,7 @@ import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { c } from 'tar'
 import { expect, it } from 'vitest'
+import { healIsolatedProfileModuleFallback, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
 import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
 import { resolveDesktopPaths } from '../src/paths.ts'
 import { runtimeFixture, writePackage } from './runtime-fixture.ts'
@@ -45,7 +46,14 @@ it('installs a real pnpm graph and executes scripts approved by user configurati
     runtimeFixture(dsh)
     const pnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
     const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
-    const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} }
+    const installAnchor = join(dsh, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
+    const hooks: DesktopProjectHooks = {
+      beforeChange: async () => {},
+      afterChange: async () => {
+        const profile = loadProfileDirectory('desktop test', manager.paths.profile, installAnchor)
+        healIsolatedProfileModuleFallback({ installAnchor, profile })
+      },
+    }
     await manager.applyRelease()
     writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`)
     writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n  - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n  node-pty: true\n`)

+ 117 - 39
apps/desktop/tests/profile-packages.spec.ts

@@ -1,48 +1,126 @@
-import { execFileSync } from 'node:child_process'
-import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
+import { dirname, join } from 'node:path'
 import { afterEach, expect, it } from 'vitest'
-import { createPluginProfile } from '../src/project-manager.ts'
-import { linkDesktopHostPackages, unlinkDesktopHostPackages } from '../src/profile-packages.ts'
-import { runtimeFixture, writePackage } from './runtime-fixture.ts'
+import { DESKTOP_PROFILE_STATE, migrateDesktopProfileLinks } from '../src/profile-packages.ts'
 
 const roots: string[] = []
+const symlinks: string[] = []
+
 function fixture() {
-  const root = mkdtempSync(join(tmpdir(), 'desktop-profile-'))
+  const root = mkdtempSync(join(tmpdir(), 'desktop-profile-migration-'))
   roots.push(root)
-  const dsh = join(root, 'dsh')
-  const runtime = runtimeFixture(dsh)
   const profile = join(root, 'profile')
-  createPluginProfile(profile)
-  linkDesktopHostPackages(profile, dsh, runtime)
-  return { root, dsh, runtime, profile }
+  const target = join(root, 'runtime-package')
+  mkdirSync(join(profile, 'node_modules'), { recursive: true })
+  mkdirSync(target)
+  return { root, profile, target }
+}
+
+function link(target: string, path: string): void {
+  mkdirSync(dirname(path), { recursive: true })
+  symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir')
+  symlinks.push(path)
+}
+
+function writeState(profile: string, links: unknown): void {
+  writeFileSync(join(profile, DESKTOP_PROFILE_STATE), JSON.stringify({
+    schemaVersion: 'obsolete', runtimeId: null, version: 8, lockHash: false, links,
+  }))
 }
-afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
-
-it('loads one shared ESM instance from both host and external plugin while keeping ordinary dependencies private', () => {
-  const { dsh, profile } = fixture()
-  writePackage(join(dsh, 'node_modules'), 'ordinary', {}, 'export default "host"')
-  writePackage(join(profile, 'node_modules'), 'ordinary', {}, 'export default "plugin"')
-  const plugin = writePackage(join(profile, 'node_modules'), 'plugin', {
-    peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dependencies: { ordinary: '1.0.0' },
-  }, 'export { identity } from "@deepseek-ai/cordis"; export { default as ordinary } from "ordinary"')
-  const entry = join(dsh, 'check.mjs')
-  writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import ordinary from 'ordinary'; import * as plugin from ${JSON.stringify(pathToFileURL(join(plugin, 'index.js')).href)}; console.log(JSON.stringify({same:identity===plugin.identity, host:ordinary, plugin:plugin.ordinary}))`)
-  const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } })
-  expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' })
-})
-it('removes broken owned links without following them', () => {
-  const { root, profile } = fixture()
-  writePackage(join(profile, 'node_modules'), 'plugin')
-  rmSync(join(root, 'dsh'), { recursive: true })
-  expect(() =>{  unlinkDesktopHostPackages(profile) }).not.toThrow()
-})
-it('preserves a pnpm package that replaced a managed link', () => {
-  const { profile } = fixture()
-  unlinkSync(join(profile, 'node_modules/@deepseek-ai/cordis'))
-  writePackage(join(profile, 'node_modules'), '@deepseek-ai/cordis')
-  expect(() =>{  unlinkDesktopHostPackages(profile) }).not.toThrow()
-  expect(existsSync(join(profile, 'node_modules/@deepseek-ai/cordis/package.json'))).toBe(true)
+
+afterEach(() => {
+  for (const path of symlinks.splice(0).reverse()) {
+    try {
+      if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    }
+  }
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+it('removes recorded links and state without interpreting obsolete runtime fields', () => {
+  const { profile, target } = fixture()
+  const packagePath = join(profile, 'node_modules', '@deepseek-ai', 'cordis')
+  link(target, packagePath)
+  writeFileSync(join(target, 'package.json'), '{"name":"@deepseek-ai/cordis"}')
+  writeState(profile, [{ name: '@deepseek-ai/cordis', target }])
+  migrateDesktopProfileLinks(profile)
+  expect(existsSync(packagePath)).toBe(false)
+  expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
+  expect(readFileSync(join(target, 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')
+  expect(() => { migrateDesktopProfileLinks(profile) }).not.toThrow()
+})
+
+it('removes an owned broken link without following its target', () => {
+  const { profile, target } = fixture()
+  const packagePath = join(profile, 'node_modules', 'plugin')
+  link(target, packagePath)
+  writeState(profile, [{ name: 'plugin', target }])
+  rmdirSync(target)
+  migrateDesktopProfileLinks(profile)
+  expect(() => lstatSync(packagePath)).toThrow()
+  expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
+})
+
+it('preserves a pnpm-installed directory replacing a recorded link', () => {
+  const { profile, target } = fixture()
+  const packagePath = join(profile, 'node_modules', 'plugin')
+  mkdirSync(packagePath)
+  writeFileSync(join(packagePath, 'package.json'), '{"name":"plugin","version":"2.0.0"}')
+  writeState(profile, [{ name: 'plugin', target }])
+  migrateDesktopProfileLinks(profile)
+  expect(readFileSync(join(packagePath, 'package.json'), 'utf8')).toContain('2.0.0')
+  expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
+})
+
+it('preserves changed and unrecorded package links', () => {
+  const { root, profile, target } = fixture()
+  const replacement = join(root, 'user-package')
+  mkdirSync(replacement)
+  const changed = join(profile, 'node_modules', 'changed')
+  const unrecorded = join(profile, 'node_modules', 'unrecorded')
+  link(replacement, changed)
+  link(target, unrecorded)
+  const changedTarget = readlinkSync(changed)
+  const unrecordedTarget = readlinkSync(unrecorded)
+  writeState(profile, [{ name: 'changed', target }])
+  migrateDesktopProfileLinks(profile)
+  expect(readlinkSync(changed)).toBe(changedTarget)
+  expect(readlinkSync(unrecorded)).toBe(unrecordedTarget)
+})
+
+it('leaves an uninitialized profile untouched', () => {
+  const { root } = fixture()
+  const missingProfile = join(root, 'absent-profile')
+  migrateDesktopProfileLinks(missingProfile)
+  expect(existsSync(missingProfile)).toBe(false)
+})
+
+it.each(['../../outside', '../outside', '@scope/../../outside', '..\\outside'])('rejects escaping package name %s before removing any links', (name) => {
+  const { root, profile, target } = fixture()
+  const packagePath = join(profile, 'node_modules', 'owned')
+  const outside = join(root, 'outside')
+  link(target, packagePath)
+  link(target, outside)
+  writeState(profile, [{ name: 'owned', target }, { name, target }])
+  expect(() => { migrateDesktopProfileLinks(profile) }).toThrow('invalid legacy package link')
+  expect(lstatSync(packagePath).isSymbolicLink()).toBe(true)
+  expect(lstatSync(outside).isSymbolicLink()).toBe(true)
+  expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(true)
+})
+
+it.each(['node_modules', 'node_modules/@scope'])('preserves package links beneath redirected %s', (parent) => {
+  const { root, profile, target } = fixture()
+  const external = join(root, 'external')
+  mkdirSync(external)
+  const packagePath = join(external, 'plugin')
+  link(target, packagePath)
+  if (parent === 'node_modules') rmdirSync(join(profile, 'node_modules'))
+  link(external, join(profile, parent))
+  writeState(profile, [{ name: parent === 'node_modules' ? 'plugin' : '@scope/plugin', target }])
+  migrateDesktopProfileLinks(profile)
+  expect(lstatSync(packagePath).isSymbolicLink()).toBe(true)
+  expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
 })

+ 25 - 37
apps/desktop/tests/project-manager.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -77,13 +77,13 @@ describe('desktop external plugin profile', () => {
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
     writeFileSync(manifest, '{broken')
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     await manager.mutate({ type: 'plugins-disable-all' }, hooks())
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(readFileSync(manifest, 'utf8')).toBe('{broken')
     await manager.resetConfiguration(hooks())
     expect(existsSync(manifest)).toBe(false)
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
   it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
@@ -102,7 +102,7 @@ describe('desktop external plugin profile', () => {
     }).dsh.profile.bundles).not.toContain('plugin')
     expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
     expect(calls(root)).toHaveLength(1)
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
   it('resets the entire profile without backups while retaining its lock and shared data', async () => {
@@ -134,7 +134,7 @@ describe('desktop external plugin profile', () => {
     }))
     expect(manager.listPlugins()).toEqual([])
     expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false)
-    expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false)
+    expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toContain('[]')
     expect(existsSync(join(profile, '.env'))).toBe(false)
     expect(existsSync(join(profile, '.extra'))).toBe(false)
     expect(existsSync(join(profile, 'external-link'))).toBe(false)
@@ -143,7 +143,7 @@ describe('desktop external plugin profile', () => {
     expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained')
     expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false)
     expect(calls(root)).toHaveLength(1)
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(existsSync(homeEnvironment)).toBe(true)
   })
 
@@ -227,34 +227,27 @@ describe('desktop external plugin profile', () => {
     mkdirSync(manager.paths.profile, { recursive: true })
     writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata')
     writeFileSync(join(manager.paths.profile, 'user-file'), 'retain')
-    await expect(manager.applyRelease()).resolves.toBe(true)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata')
     expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain')
+    expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toContain('[]')
+    expect(existsSync(join(manager.paths.profile, 'desktop-runtime-state.json'))).toBe(false)
   })
 
   it('initializes and restarts offline without executing pnpm', async () => {
     const { root, manager } = setup()
-    await expect(manager.applyRelease()).resolves.toBe(true)
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(manager.listPlugins()).toEqual([])
     expect(calls(root)).toEqual([])
-    expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
     expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
   })
 
-  it('repairs a removed managed link without running pnpm', async () => {
-    const { root, manager } = setup()
-    await manager.applyRelease()
-    unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))
-    await expect(manager.applyRelease()).resolves.toBe(true)
-    expect(calls(root)).toEqual([])
-  })
-
   it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => {
     const { manager } = setup()
     await manager.applyRelease()
     const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
-    await expect(relaunched.applyRelease()).resolves.toBe(false)
+    await expect(relaunched.applyRelease()).resolves.toBeUndefined()
   })
 
   it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
@@ -263,14 +256,14 @@ describe('desktop external plugin profile', () => {
     if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
     if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
     if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json'))
-    await expect(manager.applyRelease()).resolves.toBe(true)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
-    await expect(relaunched.applyRelease()).resolves.toBe(false)
+    await expect(relaunched.applyRelease()).resolves.toBeUndefined()
     expect(existsSync(manager.paths.profile)).toBe(true)
     expect(calls(root)).toEqual([])
   })
 
-  it('installs plugins with pnpm script settings and restores host links', async () => {
+  it('installs plugins with pnpm script settings', async () => {
     const { root, manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
@@ -280,7 +273,7 @@ describe('desktop external plugin profile', () => {
     ])
     expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true)
     expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(calls(root)).toHaveLength(1)
   })
 
@@ -288,11 +281,9 @@ describe('desktop external plugin profile', () => {
     const { manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis@2.0.0' }, hooks())
-    expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: true }])
-    await expect(manager.applyRelease()).resolves.toBe(false)
+    expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }])
+    await expect(manager.applyRelease()).resolves.toBeUndefined()
     await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks())
-    expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')))
-      .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
   })
 
   it('retains disabled plugin versions through updates and enables them explicitly', async () => {
@@ -318,12 +309,11 @@ describe('desktop external plugin profile', () => {
     const nextRoot = join(root, 'relocated', 'dsh')
     runtimeFixture(nextRoot, '1.1.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
-    await expect(next.applyRelease()).resolves.toBe(true)
+    await expect(next.applyRelease()).resolves.toBeUndefined()
     expect(next.listPlugins()).toEqual(manager.listPlugins())
-    expect(next.releaseVersion()).toBe('1.1.0')
+    expect(next.dshVersion()).toBe('1.1.0')
     expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
     expect(calls(root)).toHaveLength(1)
-    expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis')))
     expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
   })
 
@@ -346,10 +336,10 @@ describe('desktop external plugin profile', () => {
     const dsh = join(root, 'next-major')
     runtimeFixture(dsh, '2.0.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
-    await expect(next.applyRelease()).resolves.toBe(true)
-    expect(next.releaseVersion()).toBe('2.0.0')
+    await expect(next.applyRelease()).resolves.toBeUndefined()
+    expect(next.dshVersion()).toBe('2.0.0')
     await next.mutate({ type: 'plugins-disable-all' }, hooks())
-    expect(next.releaseVersion()).toBe('2.0.0')
+    expect(next.dshVersion()).toBe('2.0.0')
     expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
   })
 
@@ -368,7 +358,7 @@ describe('desktop external plugin profile', () => {
     expect(starts).toBe(phase === 'before' ? 0 : 1)
   })
 
-  it('keeps partial package changes and restores host links after pnpm fails', async () => {
+  it('keeps partial package changes available for recovery after pnpm fails', async () => {
     const { root, manager } = setup()
     await manager.applyRelease()
     const failingPnpm = join(root, 'failing.mjs')
@@ -382,8 +372,6 @@ describe('desktop external plugin profile', () => {
     expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
     expect(starts).toBe(0)
     expect(existsSync(manager.paths.lock)).toBe(false)
-    expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')))
-      .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
     writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
     await manager.applyRelease()
     await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { starts++ } }))

+ 10 - 2
apps/desktop/tests/runtime-fixture.ts

@@ -26,12 +26,20 @@ export function writePackage(modules: string, name: string, fields: Record<strin
  * Seal a minimal release containing Host entry files and a shared Cordis package.
  * @param root - New runtime directory.
  * @param version - Shell and dsh version.
- * @param nodeVersion - Bundled Node version used for native rebuild selection.
+ * @param nodeVersion - Bundled Node version recorded in resource metadata.
  * @returns Sealed runtime metadata.
  */
 export function runtimeFixture(root: string, version = '1.0.0', nodeVersion = '24.17.0'): DesktopRuntimeDescriptor {
   const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/cordis']
-  for (const name of names) writePackage(join(root, 'node_modules'), name, { version })
+  for (const name of names) {
+    const bundle = name === '@deepseek-ai/dsh-base' || name === '@deepseek-ai/dsh-web-app'
+    const path = writePackage(join(root, 'node_modules'), name, {
+      version,
+      ...(name === '@deepseek-ai/dsh' ? { dependencies: Object.fromEntries(names.slice(1).map(dependency => [dependency, version])) } : {}),
+      ...(bundle ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}),
+    })
+    if (bundle) writeFileSync(join(path, 'bundle.yml'), '[]\n')
+  }
   for (const file of DESKTOP_HOST_RUNTIME_FILES) {
     const path = join(root, 'node_modules', '@deepseek-ai/dsh-desktop-host', file)
     mkdirSync(join(path, '..'), { recursive: true })

+ 2 - 2
apps/desktop/tests/runtime-tree.spec.ts

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, expect, it } from 'vitest'
 import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts'
-import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
 import { runtimeFixture } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -19,7 +19,7 @@ it('verifies a runtime after relocation without depending on build paths', async
   const root = fixture()
   const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
   cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true })
-  expect(desktopRuntimeId(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before))
+  expect(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0')).toEqual(before)
 })
 it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => {
   const dsh = join(fixture(), 'dsh')

+ 1 - 0
apps/desktop/tsconfig.json

@@ -6,6 +6,7 @@
   },
   "include": ["src"],
   "references": [
+    { "path": "../../packages/boot/app-boot" },
     { "path": "../../packages/util/home-paths" }
   ]
 }

+ 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: 2a3c291c5acb7e94bd1f666609af901e928671bc
-architecture.zh.md: 0eea56b18ae79b297178dfb831ce78e5aed66699
+architecture.md: 95a134253612a78001feaa8990a67810618a2755
+architecture.zh.md: 26f9399c9e36b98e16a458c9b9c4078708b11acf

+ 2 - 2
docs/architecture.md

@@ -48,9 +48,9 @@ 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 application resources. The reserved `$DSH_HOME/profiles/desktop` contains external plugins and links to host-owned packages; compatible upgrades retain plugin files and refresh these links without installing core dependencies. CLI profiles share supported product data under `$DSH_HOME`, while executable packages, plugin activation, lockfiles, and package-manager state remain separate.
+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.
 
-Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the bundled dsh backend and matching client graph together with enabled profile plugins. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`.
+Electron starts the private Desktop Host under bundled upstream Node.js. The Host invokes the shared CLI profile runner and complete Web application; Electron loads its authenticated HTTP URL. Web serves RPC, streams, and client assets, while Node IPC carries 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.
 
 ## Core packages
 

+ 2 - 2
docs/architecture.zh.md

@@ -48,9 +48,9 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
 
 ## 桌面应用
 
-[Electron 桌面应用](../apps/desktop/README.zh.md)在签名应用资源中携带精确版本的 dsh 生产运行时。保留的 `$DSH_HOME/profiles/desktop` 保存外部插件和指向宿主拥有包的链接;兼容升级保留插件文件并刷新这些链接,无需安装核心依赖。CLI profile 共享 `$DSH_HOME` 下受支持的产品数据,而可执行包、插件激活、锁文件和包管理器状态保持独立
+[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 profile helper 初始化其文件、协调已安装 bundle,并补全缺失依赖而不替换 pnpm 拥有的包。CLI 与 Desktop 共享产品数据,可执行包、启用选择与锁文件保持独立。公开 CLI 不能管理 Desktop profile
 
-Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包加载内置 dsh 后端、匹配的客户端图和已启用的 profile 插件。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务
+Electron 使用内置上游 Node.js 启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用,Electron 加载其认证 HTTP URL。Web 提供 RPC、流及客户端资源,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: eeb0f682ac2a3fe9092c5b7cd132e04b4871b2aa
-README.zh.md: 75b8b832eaeef365247ea3d8a5c417a04567e9bb
+README.md: 2ba6cd6e2b191e0c48cf245db42f39fe605546b9
+README.zh.md: 6dc40e4de84374e6eaf7e631d43368044efae002

+ 5 - 0
packages/boot/app-boot/README.md

@@ -49,6 +49,8 @@ Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-mani
 
 A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
 
+`initProfile` creates missing manifest, empty user patch, and pnpm workspace files while preserving existing files. CLI and Desktop share installed-dependency discovery and bundle-list reconciliation: the same installation-first resolver determines bundle declarations, unavailable installed metadata leaves dependencies visible for repair, and ordinary dependencies stay inactive. Callers explicitly choose whether disabled bundles remain disabled; CLI activates installed bundles, while Desktop retains its UI activation choices.
+
 Your machine-local preferences also live in the Harness home:
 
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
@@ -87,6 +89,8 @@ This section explains how the outcomes above are realized and points at the code
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. Application-owned filesystem profiles use the same projection for their installation closure, so missing peers resolve inside their own profile without maintaining a shared fallback directory.
+
+- **Package-operation cleanup.** The shared fallback owner removes only the profile links it created before a package-manager operation. pnpm-managed entries remain untouched, and the profile runner recreates required installation and bundle links at the next boot. Desktop uses this owner without a separate runtime-state or lockfile-hash reconciliation scheme.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
 
@@ -100,6 +104,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 |---|---|
 | [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section |
 | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback |
+| [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 
 </details>

+ 5 - 0
packages/boot/app-boot/README.zh.md

@@ -49,6 +49,8 @@ Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../
 
 profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
 
+`initProfile` 创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,同时保留现有文件。CLI 与 Desktop 共用已安装依赖发现及 bundle 列表协调:同一个安装目录优先的解析器判断 bundle 声明,已安装元数据不可用时依赖仍可见以供修复,普通依赖保持未激活。调用者显式选择是否保留禁用的 bundle;CLI 激活已安装 bundle,Desktop 保留 UI 中的启用选择。
+
 你的机器本地偏好同样位于 harness home 中:
 
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
@@ -87,6 +89,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。应用自有的文件系统 profile 使用同一投影机制补全安装闭包,因此缺失的 peer 可在自己的 profile 内解析,无需维护共享后备目录。
+
+- **包操作清理。** 共享模块补全逻辑在包管理器操作前仅移除自己创建的 profile 链接。pnpm 管理的条目保持不变,profile runner 在下次启动时重新创建所需的安装包及 bundle 链接。Desktop 使用这一归属机制,不维护独立的运行时状态或锁文件哈希协调流程。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
 
@@ -100,6 +104,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 |---|---|
 | [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 |
 | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 |
+| [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 </details>

+ 6 - 0
packages/boot/app-boot/src/index.ts

@@ -20,6 +20,11 @@ import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
+export {
+  readProfilePlugins, reconcileProfilePlugins, writeProfileBundles,
+  type ProfilePluginLocation, type ProfilePluginDependency, type ProfilePluginInventory, type ProfilePluginReconciliation,
+} from './profile-plugins.ts'
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Harness-home path resolver available to Loader `!!js` config expressions. */
@@ -33,6 +38,7 @@ export {
   DEFAULT_PROFILE_PATCH_RELOAD,
   healProfilesModuleFallback,
   healIsolatedProfileModuleFallback,
+  unlinkProfileModuleFallback,
   initProfile,
   loadProfile,
   loadProfileDirectory,

+ 127 - 0
packages/boot/app-boot/src/profile-plugins.ts

@@ -0,0 +1,127 @@
+/** Installed profile dependencies and their bundle activation after package-manager operations. */
+
+import { join } from 'node:path'
+import { readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileManifest } from './profile.ts'
+
+/** Profile directory and installation used by the shared bundle resolver. */
+export interface ProfilePluginLocation {
+  /** Diagnostic prefix for profile manifest failures. */
+  readonly binName: string
+  /** Profile package directory managed by pnpm. */
+  readonly profileDir: string
+  /** Absolute package.json path of the owning dsh installation. */
+  readonly installAnchor: string
+}
+
+/** One dependency under its package-manager key, including npm aliases. */
+export interface ProfilePluginDependency {
+  /** Dependency key used in package.json and the bundle list. */
+  readonly name: string
+  /** Installed version, or the dependency spec when installed metadata is unavailable. */
+  readonly version: string
+  /** Whether the package selected by the bundle resolver declares a patch. */
+  readonly bundle: boolean
+  /** Whether the dependency appears in the profile's active bundle list. */
+  readonly enabled: boolean
+}
+
+/** Profile manifest and dependencies in manifest order. */
+export interface ProfilePluginInventory {
+  readonly manifest: ProfileManifest
+  readonly dependencies: readonly ProfilePluginDependency[]
+}
+
+/** Result of reconciling bundle activation after a successful package operation. */
+export interface ProfilePluginReconciliation {
+  readonly plugins: ProfilePluginInventory
+  /** Newly added ordinary dependencies, for optional caller-owned diagnostics. */
+  readonly addedPlainDependencies: readonly string[]
+}
+
+/** Unavailable installed metadata must not prevent listing or removing dependencies. */
+function optionalManifest(binName: string, packageDir: string): ProfileManifest | undefined {
+  try { return readProfileManifest(binName, packageDir) } catch { return undefined }
+}
+
+/** Resolve activation metadata with the same installation precedence as profile loading. */
+function bundleManifest(location: ProfilePluginLocation, name: string): ProfileManifest | undefined {
+  let packageDir: string
+  try {
+    packageDir = resolveBundleDir(location.binName, name, location.installAnchor, location.profileDir)
+  } catch {
+    // An unresolved dependency remains visible as an ordinary package.
+    return undefined
+  }
+  return optionalManifest(location.binName, packageDir)
+}
+
+/**
+ * Read installed versions and bundle declarations without requiring loadable plugin code.
+ * Missing or unreadable installed metadata leaves the dependency visible for repair or removal.
+ * @param location - profile and installation resolution inputs.
+ * @returns dependency records in package.json order and the profile manifest.
+ */
+export function readProfilePlugins(location: ProfilePluginLocation): ProfilePluginInventory {
+  const manifest = readProfileManifest(location.binName, location.profileDir)
+  const bundles = manifest.dsh?.profile?.bundles ?? []
+  const dependencies = Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => {
+    const installed = optionalManifest(location.binName, join(location.profileDir, 'node_modules', name))
+    return {
+      name,
+      version: typeof installed?.version === 'string' ? installed.version : spec,
+      bundle: bundleManifest(location, name)?.dsh?.bundle?.patch !== undefined,
+      enabled: bundles.includes(name),
+    }
+  })
+  return { manifest, dependencies }
+}
+
+/**
+ * Write a bundle list while preserving the supplied profile's other metadata.
+ * @param profileDir - directory whose package.json is updated.
+ * @param manifest - current profile manifest, read after the package operation when applicable.
+ * @param bundles - ordered active bundle names, including any template entries.
+ * @returns the written manifest.
+ */
+export function writeProfileBundles(
+  profileDir: string, manifest: ProfileManifest, bundles: readonly string[],
+): ProfileManifest {
+  const updated = { ...manifest, dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: [...bundles] } } }
+  writeProfileManifest(profileDir, updated)
+  return updated
+}
+
+/**
+ * Reconcile installed bundle declarations after a successful package-manager operation.
+ * Dependency-managed entries disappear when removed or when their package loses its declaration;
+ * template entries retain their order and duplicates. New bundle dependencies activate automatically.
+ * @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled.
+ * @returns updated inventory and newly added ordinary dependencies for caller-owned warnings.
+ */
+export function reconcileProfilePlugins(options: ProfilePluginLocation & {
+  readonly before: ProfilePluginInventory
+  readonly preserveDisabled: boolean
+}): ProfilePluginReconciliation {
+  const after = readProfilePlugins(options)
+  const beforeNames = new Set(options.before.dependencies.map(dependency => dependency.name))
+  const afterNames = new Set(after.dependencies.map(dependency => dependency.name))
+  const bundleNames = new Set(after.dependencies.filter(dependency => dependency.bundle).map(dependency => dependency.name))
+  const disabled = new Set(options.preserveDisabled
+    ? options.before.dependencies.filter(dependency => dependency.bundle && !dependency.enabled).map(dependency => dependency.name)
+    : [])
+  const previous = after.manifest.dsh?.profile?.bundles ?? []
+  const bundles = previous.filter(name => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name))
+  for (const dependency of after.dependencies) {
+    if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name)
+  }
+  const changed = bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index])
+  const manifest = changed ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest
+  return {
+    plugins: {
+      manifest,
+      dependencies: after.dependencies.map(dependency => ({ ...dependency, enabled: bundles.includes(dependency.name) })),
+    },
+    addedPlainDependencies: after.dependencies.filter(dependency => !dependency.bundle && !beforeNames.has(dependency.name))
+      .map(dependency => dependency.name),
+  }
+}

+ 13 - 0
packages/boot/app-boot/src/profile.ts

@@ -576,6 +576,19 @@ export function healIsolatedProfileModuleFallback(options: { installAnchor: stri
   healProfileModuleFallback(options.profile, new Set(installationLinks.keys()), installationLinks)
 }
 
+/**
+ * Detach this profile's fallback links before a package-manager mutation.
+ * Installed packages and links replaced by pnpm remain untouched; the next profile launch restores fallbacks.
+ * @param profileDir - profile directory whose package mutation is serialized by the caller.
+ */
+export function unlinkProfileModuleFallback(profileDir: string): void {
+  const ownedModulesDir = join(profileDir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules')
+  if (!existsSync(ownedModulesDir)) return
+  for (const name of ownedPackageNames(ownedModulesDir)) {
+    removeProfileSymlink(join(profileDir, 'node_modules'), ownedModulesDir, name)
+  }
+}
+
 /** Heal one module-fallback generation while the cross-process writer lock is held. */
 function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[], modulesDir: string): void {
   for (const entry of entries) {

+ 125 - 0
packages/boot/app-boot/tests/profile-plugins.spec.ts

@@ -0,0 +1,125 @@
+/** Installed metadata and shared bundle activation across profile package operations. */
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { readProfilePlugins, reconcileProfilePlugins, writeProfileBundles, type ProfilePluginLocation } from '../src/profile-plugins.ts'
+
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function writeManifest(dir: string, value: unknown): void {
+  mkdirSync(dir, { recursive: true })
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(value))
+}
+
+function fixture(): ProfilePluginLocation {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-profile-plugins-'))
+  roots.push(root)
+  const profileDir = join(root, 'profile')
+  const installAnchor = join(root, 'runtime', 'package.json')
+  writeManifest(dirname(installAnchor), { name: 'fixture-runtime' })
+  writeManifest(profileDir, {})
+  return { binName: 'test', profileDir, installAnchor }
+}
+
+function installed(location: ProfilePluginLocation, name: string, bundle = true): void {
+  writeManifest(join(location.profileDir, 'node_modules', name), {
+    name, version: '1.0.0', ...(bundle ? { dsh: { bundle: { patch: 'unbuilt.yml' } } } : {}),
+  })
+}
+
+describe('profile plugin inventory', () => {
+  it('uses dependency alias keys, installed versions, and the loader installation precedence', () => {
+    const location = fixture()
+    writeManifest(location.profileDir, {
+      dependencies: { alias: 'file:../local-package', shared: '^2', library: '^1', missing: 'git+example', unversioned: 'file:../unversioned' },
+      dsh: { profile: { bundles: ['shared', 'alias'] } },
+    })
+    const source = join(location.profileDir, '..', 'local-package')
+    writeManifest(source, { name: 'original-name', version: '3.0.0', dsh: { bundle: { patch: 'missing.yml' } } })
+    mkdirSync(join(location.profileDir, 'node_modules'))
+    symlinkSync(source, join(location.profileDir, 'node_modules', 'alias'), 'junction')
+    installed(location, 'shared', false)
+    installed(location, 'library', false)
+    writeManifest(join(location.profileDir, 'node_modules', 'unversioned'), { name: 'unversioned' })
+    writeManifest(join(dirname(location.installAnchor), 'node_modules', 'shared'), {
+      name: 'shared', version: '9.0.0', dsh: { bundle: { patch: 'bundle.yml' } },
+    })
+    expect(readProfilePlugins(location).dependencies).toEqual([
+      { name: 'alias', version: '3.0.0', bundle: true, enabled: true },
+      { name: 'shared', version: '1.0.0', bundle: true, enabled: true },
+      { name: 'library', version: '1.0.0', bundle: false, enabled: false },
+      { name: 'missing', version: 'git+example', bundle: false, enabled: false },
+      { name: 'unversioned', version: 'file:../unversioned', bundle: false, enabled: false },
+    ])
+  })
+
+  it('keeps malformed installed metadata visible and accepts an empty profile', () => {
+    const location = fixture()
+    expect(readProfilePlugins(location)).toEqual({ manifest: {}, dependencies: [] })
+    writeManifest(location.profileDir, { dependencies: { broken: '^1', invalid: '^2' } })
+    installed(location, 'broken')
+    writeFileSync(join(location.profileDir, 'node_modules', 'broken', 'package.json'), '{')
+    writeManifest(join(location.profileDir, 'node_modules', 'invalid'), [])
+    expect(readProfilePlugins(location).dependencies).toEqual([
+      { name: 'broken', version: '^1', bundle: false, enabled: false },
+      { name: 'invalid', version: '^2', bundle: false, enabled: false },
+    ])
+  })
+})
+
+describe('profile plugin reconciliation', () => {
+  it.each([false, true])('reconciles declarations and removal while preserveDisabled=%s', (preserveDisabled) => {
+    const location = fixture()
+    const beforeManifest = {
+      name: 'custom-profile', private: false, custom: { keep: true },
+      dependencies: { active: '^1', disabled: '^1', gaining: '^1', losing: '^1', removed: '^1' },
+      dsh: { profile: { bundles: ['template', 'template', 'active', 'losing', 'removed'], patchReload: 'startup' } },
+    }
+    writeManifest(location.profileDir, beforeManifest)
+    for (const name of Object.keys(beforeManifest.dependencies)) installed(location, name, name !== 'gaining')
+    const before = readProfilePlugins(location)
+    writeManifest(location.profileDir, {
+      ...beforeManifest,
+      dependencies: { active: '^2', disabled: '^2', gaining: '^2', losing: '^2', added: '^1', 'new-library': '^1' },
+    })
+    installed(location, 'gaining')
+    installed(location, 'losing', false)
+    installed(location, 'added')
+    installed(location, 'new-library', false)
+    const result = reconcileProfilePlugins({ ...location, before, preserveDisabled })
+    const bundles = ['template', 'template', 'active', ...preserveDisabled ? [] : ['disabled'], 'gaining', 'added']
+    expect(result.plugins.manifest.dsh?.profile?.bundles).toEqual(bundles)
+    expect(result.plugins.dependencies.filter(dependency => dependency.enabled).map(dependency => dependency.name))
+      .toEqual(['active', ...preserveDisabled ? [] : ['disabled'], 'gaining', 'added'])
+    expect(result.addedPlainDependencies).toEqual(['new-library'])
+    expect(JSON.parse(readFileSync(join(location.profileDir, 'package.json'), 'utf8'))).toMatchObject({
+      custom: { keep: true }, private: false, dsh: { profile: { bundles, patchReload: 'startup' } },
+    })
+    const bytes = readFileSync(join(location.profileDir, 'package.json'), 'utf8')
+    expect(reconcileProfilePlugins({ ...location, before: result.plugins, preserveDisabled }).addedPlainDependencies).toEqual([])
+    expect(readFileSync(join(location.profileDir, 'package.json'), 'utf8')).toBe(bytes)
+  })
+
+  it('writes newly activated bundles when profile metadata is absent', () => {
+    const location = fixture()
+    const before = readProfilePlugins(location)
+    writeManifest(location.profileDir, { dependencies: { added: '^1' } })
+    installed(location, 'added')
+    expect(reconcileProfilePlugins({ ...location, before, preserveDisabled: false }).plugins.manifest.dsh?.profile?.bundles)
+      .toEqual(['added'])
+  })
+
+  it('removes an unresolved dependency layer while preserving custom profile metadata', () => {
+    const location = fixture()
+    writeManifest(location.profileDir, { dependencies: { missing: '^1' }, dsh: { profile: { bundles: ['missing', 'template'] } } })
+    const before = readProfilePlugins(location)
+    const reconciled = reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
+    expect(reconciled.plugins.manifest.dsh?.profile?.bundles).toEqual(['template'])
+    expect(writeProfileBundles(location.profileDir, reconciled.plugins.manifest, ['template', 'manual']).dsh?.profile?.bundles)
+      .toEqual(['template', 'manual'])
+  })
+})

+ 28 - 0
packages/boot/app-boot/tests/profile.spec.ts

@@ -18,6 +18,7 @@ import {
   healProfilesModuleFallback,
   healIsolatedProfileModuleFallback,
   initProfile,
+  unlinkProfileModuleFallback,
   loadProfile,
   loadProfileDirectory,
   PROFILE_PATCH_FILENAME,
@@ -140,6 +141,33 @@ describe('healIsolatedProfileModuleFallback', () => {
   })
 })
 
+describe('unlinkProfileModuleFallback', () => {
+  it('detaches only this profile projections and restores missing packages from a relocated installation', () => {
+    const home = tmp()
+    const anchor = stageInstallation({ fallback: {}, '@scope/peer': {}, replaced: {} })
+    const nextAnchor = stageInstallation({ fallback: {}, '@scope/peer': {}, replaced: {} })
+    const bundleAnchor = stageInstallation({}, 'selected-bundle')
+    const profile = stageProfile(home, 'desktop', bundleAnchor)
+    const other = stageProfile(home, 'other', bundleAnchor)
+    unlinkProfileModuleFallback(profile.dir)
+    healIsolatedProfileModuleFallback({ installAnchor: anchor, profile })
+    healIsolatedProfileModuleFallback({ installAnchor: anchor, profile: other })
+    const modules = join(profile.dir, 'node_modules')
+    unlinkSync(join(modules, 'replaced'))
+    mkdirSync(join(modules, 'replaced'))
+    writeFileSync(join(modules, 'replaced', 'sentinel'), 'pnpm')
+    unlinkProfileModuleFallback(profile.dir)
+    unlinkProfileModuleFallback(profile.dir)
+    expect(existsSync(join(modules, 'fallback'))).toBe(false)
+    expect(existsSync(join(modules, '@scope/peer'))).toBe(false)
+    expect(readFileSync(join(modules, 'replaced', 'sentinel'), 'utf8')).toBe('pnpm')
+    expect(existsSync(join(other.dir, 'node_modules', 'fallback'))).toBe(true)
+    healIsolatedProfileModuleFallback({ installAnchor: nextAnchor, profile })
+    expect(realpathSync(join(modules, 'fallback'))).toBe(realpathSync(join(nextAnchor, '..', 'node_modules', 'fallback')))
+    expect(readFileSync(join(modules, 'replaced', 'sentinel'), 'utf8')).toBe('pnpm')
+  })
+})
+
 describe('resolveProfileDir', () => {
   it('joins the home and rejects traversal-shaped names', () => {
     const home = tmp()

+ 3 - 0
pnpm-lock.yaml

@@ -496,6 +496,9 @@ importers:
       '@aws-sdk/client-s3':
         specifier: 3.1067.0
         version: 3.1067.0
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../../packages/boot/app-boot
       '@deepseek-ai/dsh-home-paths':
         specifier: workspace:^
         version: link:../../packages/util/home-paths