Преглед изворни кода

feat: 打包 + 错误处理优化

07akioni пре 3 недеља
родитељ
комит
d09b4accd3
48 измењених фајлова са 978 додато и 387 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 2 0
      .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. 3 1
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  6. 3 1
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml
  8. 23 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md
  9. 23 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  11. 4 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  12. 5 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  14. 25 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  15. 25 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  16. 2 2
      apps/desktop/README.i18n.yaml
  17. 10 8
      apps/desktop/README.md
  18. 10 8
      apps/desktop/README.zh.md
  19. 5 2
      apps/desktop/renderer/startup.html
  20. 18 12
      apps/desktop/renderer/startup.js
  21. 3 4
      apps/desktop/scripts/dev.ts
  22. 6 4
      apps/desktop/src/backend-controller.ts
  23. 7 0
      apps/desktop/src/ipc.ts
  24. 18 2
      apps/desktop/src/locale.ts
  25. 131 22
      apps/desktop/src/main.ts
  26. 1 7
      apps/desktop/src/paths.ts
  27. 4 0
      apps/desktop/src/preload-app.ts
  28. 20 25
      apps/desktop/src/profile-packages.ts
  29. 79 140
      apps/desktop/src/project-manager.ts
  30. 27 37
      apps/desktop/src/runtime-tree.ts
  31. 25 0
      apps/desktop/src/startup-document.ts
  32. 31 0
      apps/desktop/src/startup-error.ts
  33. 4 4
      apps/desktop/tests/backend-controller.spec.ts
  34. 8 0
      apps/desktop/tests/expected/startup-en-configuration.txt
  35. 8 0
      apps/desktop/tests/expected/startup-en-plugins.txt
  36. 8 0
      apps/desktop/tests/expected/startup-en-reinstall.txt
  37. 8 0
      apps/desktop/tests/expected/startup-en-restart.txt
  38. 8 0
      apps/desktop/tests/expected/startup-zh-CN-configuration.txt
  39. 8 0
      apps/desktop/tests/expected/startup-zh-CN-plugins.txt
  40. 8 0
      apps/desktop/tests/expected/startup-zh-CN-reinstall.txt
  41. 8 0
      apps/desktop/tests/expected/startup-zh-CN-restart.txt
  42. 133 5
      apps/desktop/tests/main-startup.spec.ts
  43. 2 2
      apps/desktop/tests/plugin-pnpm.spec.ts
  44. 5 0
      apps/desktop/tests/preload-app.spec.ts
  45. 3 6
      apps/desktop/tests/profile-packages.spec.ts
  46. 132 56
      apps/desktop/tests/project-manager.spec.ts
  47. 43 10
      apps/desktop/tests/runtime-tree.spec.ts
  48. 62 22
      apps/desktop/tests/startup-renderer.spec.ts

+ 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: f6aa73d6e708db04c6978690b8fc2b7eb70f270b
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 00c1346ab9b0bca1f704d74e7792acd03c5df9bf
+2026-08-25-electron-desktop-packaging-and-updates.md: 2e266b74c2c1a5de62312ddce2c43de0908b2675
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0a48e80b47450db7e45ab96f3a7b6e465ca271b6

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

@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
 
+Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+
 ## Problem
 
 DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow.

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

@@ -4,6 +4,8 @@ Status: implemented
 
 [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
 
+本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+
 ## 问题
 
 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。

+ 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: a662af570c017d6fd3d38cf066c0a02e0049a84f
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 9fcee1d49d4d2595fa9dfc929b58ee3e5262ac54
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: f448029cbf840e8003ce26d67788663168629da4

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

@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
 
+Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+
 ## Problem
 
 Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades.
@@ -44,7 +46,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
 
 ## Alternatives considered
 
-Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. 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 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.
 
 - **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.

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

@@ -4,6 +4,8 @@ Status: implemented
 
 [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
 
+本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+
 ## 问题
 
 Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。
@@ -44,7 +46,7 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 
 
 ## 考虑过的替代方案
 
-完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
 
 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-build-release-validation.md
+2026-09-09-desktop-build-release-validation.md: 23aeeaa98d2647f8b9f54e94ea2aedaa82b19e55
+2026-09-09-desktop-build-release-validation.zh.md: 266bca5bbd593907c9a8fadd43050113ede94100

+ 23 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md

@@ -0,0 +1,23 @@
+# Agent Note: Verify Desktop release compatibility during packaging
+
+Status: implemented
+
+English | [中文](2026-09-09-desktop-build-release-validation.zh.md)
+
+## Problem
+
+The shell and runtime descriptor ship together. Comparing their release facts on every launch repeats packaging checks without proving that installed executable bytes match the descriptor.
+
+## Decision
+
+The packaging verifier owns descriptor schema, shell version, platform, architecture, declared Host protocol version, and Node/pnpm semver validation. Startup reads the fields needed for profile preparation and retains shared-package record and Host-entry checks. The actual Host ready message still validates its protocol version.
+
+This partially supersedes startup release compatibility checks in the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). That note retains package ownership and distribution rationale.
+
+## Alternatives considered
+
+Repeating descriptor comparisons can diagnose a mixed installation earlier, but cannot establish executable integrity. Reintroducing them requires a concrete installation failure that packaging validation and actual Host diagnostics cannot adequately explain.
+
+## Consequences
+
+Startup does not reject a descriptor solely because its declared release schema, target, or Host protocol differs, or its Node/pnpm version strings are not semver. Packaging still rejects these cases and shell-version mismatch. Tests distinguish startup reads from packaging verification.

+ 23 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 在打包时验证 Desktop 发布兼容性
+
+Status: implemented
+
+[English](2026-09-09-desktop-build-release-validation.md) | 中文
+
+## 问题
+
+壳与运行时描述文件一起发布。每次启动比较其中的发布信息会重复打包检查,却不能证明已安装的可执行文件字节与描述文件一致。
+
+## 决策
+
+打包验证器负责描述文件 schema、shell 版本、平台、架构、声明的 Host 协议版本,以及 Node/pnpm semver 验证。启动读取准备 profile 所需的字段,并保留共享包记录和 Host 入口检查。实际 Host ready 消息仍然验证其协议版本。
+
+本决策部分取代[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的启动发布兼容性检查。该记录继续负责包归属与分发的理由。
+
+## 考虑过的替代方案
+
+重复比较描述文件能更早诊断混装,但不能证明可执行文件完整性。重新引入这些比较需要具体的安装故障,且打包验证和实际 Host 诊断无法充分解释该故障。
+
+## 后果
+
+启动不会仅因描述文件声明的发布 schema、目标或 Host 协议不同,或 Node/pnpm 版本字符串不是 semver 而拒绝运行。打包仍拒绝这些情况和 shell 版本不匹配。测试区分启动读取与打包验证。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-immediate-window-and-direct-start.md
-2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316
+2026-09-09-desktop-immediate-window-and-direct-start.md: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md

@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
 
+Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+
 ## Problem
 
 Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process.
@@ -12,6 +14,8 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit.
 
+The main window owns recovery because the failed Host cannot supply its own controls. Every error page retains diagnostics and exposes restart, disable-third-party-plugins, and reset-Desktop actions, plus reinstallation guidance. The actions remain available regardless of error classification. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The lock lives inside the profile, and reset preserves its directory so another transaction cannot acquire a replacement lock during cleanup. Disabled third-party package files cannot block startup when no third-party bundles are enabled. Self-contained shell recovery controls use intercepted form navigation so a failed preload cannot disable them.
+
 Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes.
 
 This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.

+ 5 - 1
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md

@@ -4,13 +4,17 @@ Status: implemented
 
 [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
 
+本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+
 ## 问题
 
 等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。
 
 ## 决策
 
-Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。
+Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断和恢复操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。
+
+主窗口负责恢复,因为故障 Host 无法提供自己的操作界面。每个错误页保留诊断,提供重启、禁用第三方插件和重置 Desktop 三个操作,以及重装提示。所有操作均不依赖错误分类而保持可用。重置删除 profile 内除当前持有的锁之外的全部内容,不留备份;共享产品数据和 Harness home 环境文件保持不变。锁位于 profile 内,重置保留目录,防止其他事务在清理期间取得替代锁。没有启用第三方 bundle 时,已禁用插件的文件不能阻止启动。独立内嵌错误页通过拦截表单导航执行恢复,因此 preload 故障不会使按钮失效。
 
 profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: c83bb9826a4068f699e330f911c52064790b08b6
+2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8

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

@@ -0,0 +1,25 @@
+# Agent Note: Modify the Desktop profile in place
+
+Status: implemented
+
+English | [中文](2026-09-09-desktop-in-place-profile.zh.md)
+
+## Problem
+
+Staging preserves an old plugin installation but adds profile copying, directory moves, a recovery journal, and rollback state. Local plugin changes accept explicit repair after failure instead of this complexity.
+
+## 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, dependency validation, and approved native builds remain. Compatible upgrades refresh 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.
+
+This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active.
+
+## Alternatives considered
+
+Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs.
+
+## Consequences
+
+Tests cover offline initialization, in-place upgrades, failure before writes, retained changes after Host failure, partial pnpm failure, restored host links, and exclusive package ownership. Signed application and GUI acceptance remain release-environment checks.

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

@@ -0,0 +1,25 @@
+# Agent Note: 直接修改 Desktop profile
+
+Status: implemented
+
+[English](2026-09-09-desktop-in-place-profile.md) | 中文
+
+## 问题
+
+staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢复日志和回滚状态。本地插件变更接受失败后显式修复,以避免这些复杂度。
+
+## 决策
+
+Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。
+
+包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
+
+本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。
+
+## 考虑过的替代方案
+
+staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。
+
+## 后果
+
+测试覆盖离线初始化、原地升级、写入前失败、Host 失败后保留修改、pnpm 部分失败、宿主链接恢复及独占包操作。签名应用和 GUI 验收仍由发布环境负责。

+ 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: 86ef7d9eb63f3b7aee89a86b5c3a4866af034ac5
-README.zh.md: ea8976b880a5d5a3df5932c700237505d5f19d5a
+README.md: 91cdd6d4ea0e001dd9ae81dc9f8e64aa5a912879
+README.zh.md: bd1381eb08477b1541b584eca7c5c5cb7c3f1cac

+ 10 - 8
apps/desktop/README.md

@@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no
 | 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. |
 | 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 | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
-| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. |
+| 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. |
 | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
 The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions.
@@ -29,17 +29,19 @@ Electron chooses typed English or Chinese shell copy from its application locale
 
 ### 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 checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
+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, then activates and starts the actual backend once.
-2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run.
+2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
-5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell.
+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; repair the installation and retry. Desktop creates no staging directories, activation journals, or rollback copies.
 
-The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts.
+The loading page does not depend on the Host. Every startup and backend error page retains diagnostics and offers three actions: restart the application, disable all third-party plugins and restart the Host, or reset Desktop and restart the Host. Reinstallation guidance is always visible for missing or damaged application files. Disabling plugins retains their files and skips their installation graph when none are enabled. The plugin manager remains available through the application menu. Runtime compatibility is checked before any backend starts; plugin changes have no automatic rollback.
 
-Package transactions hold an exclusive lock through pnpm process exit. 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 the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
+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 same three 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 the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
 
 ## Develop
 
@@ -57,7 +59,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
 pnpm run start:desktop
 ```
 
-Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths.
+Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
 
 ## Package
 
@@ -182,7 +184,7 @@ Signed packaging emits generic-provider channel metadata for the deployment sele
 
 ## Low-level development overrides
 
-`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`.
+An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
 
 ## Known limitations
 

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

@@ -14,7 +14,7 @@
 | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
 | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
 | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
-| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 |
+| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
 [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。
@@ -29,17 +29,19 @@ Electron 根据应用 locale 选择类型化的中英文字典,并以英文作
 
 ### 运行时与插件激活
 
-签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
 1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端。
-2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,并检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。
+2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
-5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。
+5. 插件变更在修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败保留已修改文件并报告错误;修复安装后重试。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
-加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。
+加载页不依赖 Host。每个启动和后端错误页均保留诊断,并提供三个操作:重启应用、禁用全部第三方插件并重启 Host、重置 Desktop 并重启 Host。页面始终显示应用文件缺失或损坏时的重装提示。禁用插件会保留文件;没有启用的第三方插件时,不再检查它们的安装依赖图。插件管理仍可从应用菜单打开。每次启动后端前都会检查运行时兼容性;插件变更不自动回滚。
 
-包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
+重置会删除 `$DSH_HOME/profiles/desktop` 内除当前持有的事务锁之外的全部内容,再初始化内置 profile。Desktop 配置和已安装第三方插件会被删除,不保留备份。共享任务、设置和 Harness home 的 `.env` 不受影响。壳资源或 preload 故障使用独立内嵌页面,保留相同的三个操作和诊断;其按钮不依赖 preload。
+
+包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
 
 ## 开发
 
@@ -57,7 +59,7 @@ pnpm run dev:desktop
 pnpm run start:desktop
 ```
 
-Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。
+Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
 
 ## 打包
 
@@ -182,7 +184,7 @@ pnpm run prepare:desktop
 
 ## 底层开发覆盖项
 
-`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。
+未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
 
 ## 已知限制
 

+ 5 - 2
apps/desktop/renderer/startup.html

@@ -12,10 +12,13 @@
       <div id="spinner" aria-hidden="true"></div>
       <h1 id="title" role="status" aria-live="polite"></h1>
       <p id="description"></p>
+      <p id="reset-advice" hidden></p>
+      <p id="reinstall-advice" hidden></p>
       <pre id="error" role="alert" hidden></pre>
       <div id="actions" hidden>
-        <button id="retry" type="button"></button>
-        <button id="plugins" class="secondary" type="button"></button>
+        <button id="restart" type="button"></button>
+        <button id="disable-plugins" type="button"></button>
+        <button id="reset-configuration" type="button"></button>
       </div>
     </main>
     <script src="startup.js"></script>

+ 18 - 12
apps/desktop/renderer/startup.js

@@ -4,8 +4,11 @@ async function main() {
   const { id, messages } = await api.locale()
   document.documentElement.lang = id
   document.querySelector('#page-title').textContent = messages.startupLoading
-  document.querySelector('#retry').textContent = messages.retry
-  document.querySelector('#plugins').textContent = messages.managePlugins
+  document.querySelector('#restart').textContent = messages.restartApplication
+  document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins
+  document.querySelector('#reset-configuration').textContent = messages.resetConfiguration
+  document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice
+  document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice
   function render(state) {
     const failed = state.phase === 'error'
     document.querySelector('main').setAttribute('aria-busy', String(!failed))
@@ -15,23 +18,26 @@ async function main() {
     document.querySelector('#error').hidden = !failed
     document.querySelector('#error').textContent = failed ? state.message : ''
     document.querySelector('#actions').hidden = !failed
-    document.querySelector('#retry').disabled = !failed
-    document.querySelector('#plugins').disabled = !failed
+    for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
+    document.querySelector('#reset-advice').hidden = !failed
+    document.querySelector('#reinstall-advice').hidden = !failed
   }
   let changed = false
   const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) })
   window.addEventListener('pagehide', unsubscribe, { once: true })
   const initial = await api.backend.status()
   if (!changed) render(initial)
-  document.querySelector('#retry').addEventListener('click', async () => {
+  async function recover(operation) {
     render({ phase: 'starting' })
-    try { await api.backend.retry() }
-    catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) }
-  })
-  document.querySelector('#plugins').addEventListener('click', async () => {
-    try { await api.openPlugins() }
-    catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) }
-  })
+    try { await operation() }
+    catch (error) {
+      const current = await api.backend.status().catch(() => undefined)
+      render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) })
+    }
+  }
+  document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) })
+  document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) })
+  document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) })
 }
 
 void main()

+ 3 - 4
apps/desktop/scripts/dev.ts

@@ -53,7 +53,7 @@ async function runPackageScript(script: string, cwd: string): Promise<void> {
   await run(process.execPath, [packageManager, 'run', script], cwd)
 }
 
-async function launchElectron(projectDir: string): Promise<void> {
+async function launchElectron(): Promise<void> {
   const require = createRequire(import.meta.url)
   const electron: unknown = require('electron')
   if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable')
@@ -65,7 +65,6 @@ async function launchElectron(projectDir: string): Promise<void> {
   const environment: NodeJS.ProcessEnv = {
     ...process.env,
     DSH_HOME: home,
-    DSH_DESKTOP_DEV_PROJECT_DIR: projectDir,
     DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
     DSH_DESKTOP_NODE_BINARY: process.execPath,
     DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
@@ -102,14 +101,14 @@ async function main(): Promise<void> {
     nodeVersion: process.versions.node,
     pnpmVersion,
   }
-  const projectDir = prepareDevelopmentProject({
+  prepareDevelopmentProject({
     projectDir: join(DEVELOPMENT_ROOT, 'project'),
     cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'),
     hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'),
     dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
     release,
   })
-  await launchElectron(projectDir)
+  await launchElectron()
 }
 
 main().catch((error: unknown) => {

+ 6 - 4
apps/desktop/src/backend-controller.ts

@@ -1,7 +1,9 @@
 /** Owns one backend startup and its quiescent teardown independently of windows. */
 
+import { desktopErrorState, type DesktopRecovery } from './startup-error.ts'
+
 /** Backend availability presented by the desktop window. */
-export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string }
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly recovery?: DesktopRecovery }
 
 /** Child lifecycle owned by the desktop backend controller. */
 export interface DesktopBackendHost {
@@ -78,7 +80,7 @@ export class DesktopBackendController<Host extends DesktopBackendHost> {
         try { await this.cleanup(attempt) } catch (cleanupError) {
           if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed')
         }
-        if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) })
+        if (!cancelled) this.update(desktopErrorState(failure))
         throw failure
       }
     }).finally(() => { if (this.pending === pending) this.pending = undefined })
@@ -130,9 +132,9 @@ export class DesktopBackendController<Host extends DesktopBackendHost> {
     if (this.current.phase !== 'ready') return
     attempt.cancelled = true
     const cleanup = this.cleanup(attempt)
-    this.update({ phase: 'error', message: error.message })
+    this.update(desktopErrorState(error))
     void cleanup.catch((cleanupError: unknown) => {
-      if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) })
+      if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop')))
     })
   }
 

+ 7 - 0
apps/desktop/src/ipc.ts

@@ -16,6 +16,9 @@ export const DESKTOP_IPC = {
   pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
   backendStatus: 'dsh-desktop:backend-status',
   backendRetry: 'dsh-desktop:backend-retry',
+  applicationRestart: 'dsh-desktop:application-restart',
+  applicationClose: 'dsh-desktop:application-close',
+  configurationReset: 'dsh-desktop:configuration-reset',
   backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
@@ -56,4 +59,8 @@ export interface DshDesktopApi {
 /** Startup-page controls, unavailable to backend-provided application documents. */
 export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale' | 'backend'> {
   openPlugins(): Promise<void>
+  disablePlugins(): Promise<void>
+  restart(): Promise<void>
+  close(): Promise<void>
+  resetConfiguration(): Promise<void>
 }

+ 18 - 2
apps/desktop/src/locale.ts

@@ -5,7 +5,15 @@ export const en = {
   startupFailed: 'DeepSeek Harness could not start',
   startupLoading: 'Starting DeepSeek Harness…',
   startupLoadingDescription: 'Your workspace will open when it is ready.',
-  startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.',
+  startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.',
+  startupRestartAdvice: 'Close and restart the application. If the problem continues, keep the error details for troubleshooting.',
+  startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
+  startupPluginsAdvice: 'A third-party plugin may be preventing startup. Disable all third-party plugins and try again. Plugin files and tasks are retained.',
+  startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.',
+  restartApplication: 'Close and restart',
+  closeApplication: 'Close application',
+  resetConfiguration: 'Reset Desktop and retry',
+  disableThirdPartyPlugins: 'Disable all third-party plugins and retry',
   managePlugins: 'Manage plugins',
   pluginsMenu: 'Desktop Plugins…',
   pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)',
@@ -55,7 +63,15 @@ export const zh = {
   startupFailed: 'DeepSeek Harness 无法启动',
   startupLoading: '正在启动 DeepSeek Harness…',
   startupLoadingDescription: '准备就绪后将自动打开工作区。',
-  startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。',
+  startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。',
+  startupRestartAdvice: '请关闭并重启应用。如果问题持续出现,请保留错误详情以便排查。',
+  startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
+  startupPluginsAdvice: '第三方插件可能导致应用无法启动。请禁用全部第三方插件后重试。插件文件和任务数据会保留。',
+  startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。',
+  restartApplication: '关闭并重启',
+  closeApplication: '关闭应用',
+  resetConfiguration: '重置 Desktop 并重试',
+  disableThirdPartyPlugins: '禁用全部第三方插件并重试',
   managePlugins: '管理插件',
   pluginsMenu: '桌面插件…',
   pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)',

+ 131 - 22
apps/desktop/src/main.ts

@@ -20,9 +20,26 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
+import { desktopErrorState, DesktopStartupError } from './startup-error.ts'
+import { startupFailureDocument } from './startup-document.ts'
 
 const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
+type RecoveryAction = 'restart' | 'plugins' | 'reset'
+const emergencyPages = new WeakMap<BrowserWindow, { url: string; message: string; busy: boolean }>()
+let recoverApplication = (action: RecoveryAction): Promise<void> => {
+  if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application'))
+  app.relaunch()
+  app.quit()
+  return Promise.resolve()
+}
+
+async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
+  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message)
+  const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
+  emergencyPages.set(window, { url, message, busy: false })
+  await window.loadURL(url)
+}
 
 function errorOf(reason: unknown, fallback: string): Error {
   return reason instanceof Error ? reason : new Error(fallback)
@@ -63,13 +80,6 @@ function runtimeResources(): RuntimeResources {
   return { node, pnpm, dsh }
 }
 
-function developmentProject(): string | undefined {
-  const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR
-  if (configured === undefined || configured === '') return undefined
-  if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications')
-  return resolve(configured)
-}
-
 function developmentHostInspectPort(enabled: boolean): number | undefined {
   const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
   if (!enabled || configured === undefined || configured === '') return undefined
@@ -98,6 +108,14 @@ function createWindow(preload: string, show = false): BrowserWindow {
   window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
   window.webContents.on('will-navigate', (event, url) => {
     if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault()
+    const page = emergencyPages.get(window)
+    if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return
+    const action = new URL(url)
+    if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return
+    page.busy = true
+    void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => {
+      if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`)
+    }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false })
   })
   return window
 }
@@ -134,11 +152,10 @@ async function serveShellAsset(request: Request): Promise<Response> {
 async function main(): Promise<void> {
   const resources = runtimeResources()
   const paths = resolveDesktopPaths()
-  const development = developmentProject()
+  const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project')
   const activeProject = development ?? paths.profile
-  const hostInspectPort = developmentHostInspectPort(development !== undefined)
   const manager = new DesktopProjectManager(paths, resources)
-  let pageError: string | undefined
+  let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
   let quitting = false
   let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
@@ -152,10 +169,19 @@ async function main(): Promise<void> {
   const startupUrl = `${SCHEME}://shell/startup.html`
   const applicationUrl = `${SCHEME}://app/index.html`
   let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
+  let emergencyDocument = false
+
+  const showEmergencyError = async (error: unknown): Promise<void> => {
+    if (quitting || emergencyDocument) return
+    emergencyDocument = true
+    const diagnostic = desktopErrorState(error).message
+    pageError = { phase: 'error', message: diagnostic, recovery: 'reinstall' }
+    if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
+  }
 
   const navigateMain = (url: string): Promise<void> => {
     const window = mainWindow
-    if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve()
+    if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
     if (navigation?.window === window && navigation.url === url) return navigation.promise
     const next = { window, url, promise: Promise.resolve() }
     next.promise = window.loadURL(url).catch((error: unknown) => {
@@ -167,7 +193,7 @@ async function main(): Promise<void> {
     return next.promise
   }
   const backendState = (): DesktopBackendState => pageError === undefined
-    ? backend.state : { phase: 'error', message: pageError }
+    ? backend.state : pageError
   const publishBackend = (state: DesktopBackendState): void => {
     for (const window of BrowserWindow.getAllWindows()) {
       window.webContents.send(DESKTOP_IPC.backendState, state)
@@ -175,9 +201,32 @@ async function main(): Promise<void> {
   }
   const backend = new DesktopBackendController((onFailure) => {
     if (development === undefined) manager.assertProfileRuntime(activeProject)
-    return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure)
+    const hostInspectPort = developmentHostInspectPort(development !== undefined)
+    const pluginsEnabled = development === undefined && manager.hasEnabledPlugins()
+    const classify = (error: Error): Error => {
+      if (error instanceof DesktopStartupError) return error
+      if ((error.message.includes(resources.node) || error.message.includes(resources.dsh))
+        && /ENOENT|Cannot find|failed to (?:read|parse) overlay/u.test(error.message)) {
+        return new DesktopStartupError('reinstall', error)
+      }
+      if (error.message.includes('only the launching environment may set')
+        || /failed to (?:read|parse) (?:overlay|patches)|must be a top-level YAML array|patchReload must be/u.test(error.message)) {
+        return new DesktopStartupError('configuration', error)
+      }
+      return pluginsEnabled ? new DesktopStartupError('plugins', error) : error
+    }
+    const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env,
+      (error) => { onFailure(classify(error)) })
+    return {
+      async start() {
+        try { return await host.start() }
+        catch (error) { throw classify(errorOf(error, messages.startupFailed)) }
+      },
+      stop: () => host.stop(),
+      fetch: (request: Request) => host.fetch(request),
+    }
   }, (state) => {
-    if (state.phase === 'starting') pageError = undefined
+    if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
     publishBackend(backendState())
     if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
   })
@@ -191,14 +240,34 @@ async function main(): Promise<void> {
   }
 
   const hooks: DesktopProjectHooks = {
-    beforeActivate: () => backend.stop(),
-    afterActivate: () => backend.start(async () => {}),
+    beforeChange: () => backend.stop(),
+    afterChange: () => backend.start(async () => {}),
+  }
+
+  recoverApplication = async (action): Promise<void> => {
+    await startup?.catch(() => undefined)
+    await backend.stop()
+    if (action === 'restart') {
+      app.relaunch()
+      app.quit()
+      return
+    }
+    if (development !== undefined) throw new Error('Desktop profile recovery requires a packaged application')
+    if (action === 'reset') await manager.resetConfiguration(hooks)
+    else await manager.mutate({ type: 'plugins-disable-all' }, hooks)
+    emergencyDocument = false
+    pageError = undefined
+    navigation = undefined
+    await navigateMain(applicationUrl)
   }
 
   const showStartupError = async (error: unknown): Promise<void> => {
     if (quitting) return
-    pageError = errorOf(error, messages.startupFailed).message
-    await navigateMain(startupUrl)
+    pageError = desktopErrorState(error)
+    try { await navigateMain(startupUrl) }
+    catch (navigationError) {
+      await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
+    }
     publishBackend(backendState())
   }
   const reconcileBackend = (): Promise<void> => {
@@ -207,7 +276,7 @@ async function main(): Promise<void> {
       await navigateMain(startupUrl)
       await backend.start(async () => {
         if (development === undefined) {
-          await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} })
+          await manager.applyRelease()
         }
       })
       if (backend.host !== undefined) await navigateMain(applicationUrl)
@@ -228,7 +297,13 @@ async function main(): Promise<void> {
 
   protocol.handle(SCHEME, (request) => {
     const url = new URL(request.url)
-    if (url.hostname === 'shell') return serveShellAsset(request)
+    if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
+      if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
+        void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
+          .catch((error: unknown) => { console.error(error) })
+      }
+      return response
+    })
     if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 }))
     const active = backend.host
     if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 }))
@@ -288,6 +363,32 @@ async function main(): Promise<void> {
     await reconcileBackend()
     focusPrimaryWindow()
   })
+  ipcMain.handle(DESKTOP_IPC.applicationClose, (event) => {
+    assertDesktopSender(event, ['shell'])
+    app.quit()
+  })
+  ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
+    assertDesktopSender(event, ['shell'])
+    try {
+      await recoverApplication('restart')
+    } catch (error) {
+      await showStartupError(error)
+    }
+  })
+  ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => {
+    assertDesktopSender(event, ['shell'])
+    if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application')
+    const failure = backendState()
+    if (failure.phase !== 'error') {
+      throw new Error('Desktop profile reset requires a startup failure')
+    }
+    await startup?.catch(() => undefined)
+    try {
+      await recoverApplication('reset')
+    } catch (error) {
+      await showStartupError(error)
+    }
+  })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
     return updates.check()
@@ -375,6 +476,13 @@ async function main(): Promise<void> {
     const window = createWindow(appPreload, true)
     mainWindow = window
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    window.webContents.on('preload-error', (_event, _path, error) => {
+      void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
+    })
+    window.webContents.on('render-process-gone', (_event, details) => {
+      void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
+        .catch((failure: unknown) => { console.error(failure) })
+    })
     return window
   }
   focusPrimaryWindow = () => {
@@ -425,6 +533,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk
   if (diagnosticFile !== undefined) {
     await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
   }
-  dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message)
-  app.exit(1)
+  const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true)
+  window.once('closed', () => { app.quit() })
+  await showEmergencyDocument(window, message)
 })

+ 1 - 7
apps/desktop/src/paths.ts

@@ -7,9 +7,6 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 export interface DesktopPaths {
   readonly root: string
   readonly profile: string
-  readonly staging: string
-  readonly rollback: string
-  readonly pending: string
   readonly lock: string
   readonly pnpm: {
     readonly root: string
@@ -32,10 +29,7 @@ export function resolveDesktopPaths(dshHome: string = resolveDshHome()): Desktop
   return {
     root,
     profile: join(dshHome, 'profiles', 'desktop'),
-    staging: join(root, 'staging'),
-    rollback: join(root, 'rollback', 'profile'),
-    pending: join(root, 'pending.json'),
-    lock: join(root, 'lock'),
+    lock: join(dshHome, 'profiles', 'desktop', 'lock'),
     pnpm: {
       root: pnpm,
       store: join(pnpm, 'store'),

+ 4 - 0
apps/desktop/src/preload-app.ts

@@ -17,6 +17,10 @@ const startup: DshDesktopStartupApi = {
     },
   },
   openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise<void>,
+  disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
+  restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
+  close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise<void>,
+  resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
 }
 
 contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'

+ 20 - 25
apps/desktop/src/profile-packages.ts

@@ -1,10 +1,11 @@
 /** Desktop-owned host links and validation of the external plugin dependency graph. */
 
 import { createHash } from 'node:crypto'
-import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import { satisfies } from 'semver'
+import { DesktopStartupError } from './startup-error.ts'
 import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 
 /** Applied runtime identity and the only links Desktop may replace. */
@@ -52,6 +53,11 @@ function inside(root: string, path: string): boolean {
  * @returns Validated state, or undefined for an uninitialized profile.
  */
 export function readDesktopProfileState(profile: string): DesktopProfileState | undefined {
+  try { return readProfileState(profile) }
+  catch (error) { throw new DesktopStartupError('configuration', error) }
+}
+
+function readProfileState(profile: string): DesktopProfileState | undefined {
   const path = join(profile, DESKTOP_PROFILE_STATE)
   if (!existsSync(path)) return undefined
   const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
@@ -85,7 +91,7 @@ export function desktopPluginLockHash(profile: string): string {
 
 /**
  * Remove only recorded host links, without following even broken targets.
- * @param profile - Active or copied profile.
+ * @param profile - Desktop profile.
  */
 export function unlinkDesktopHostPackages(profile: string): void {
   for (const link of readDesktopProfileState(profile)?.links ?? []) {
@@ -99,25 +105,6 @@ export function unlinkDesktopHostPackages(profile: string): void {
   }
 }
 
-/**
- * Copy plugin files without copying host packages or sharing writable hardlinks.
- * @param source - Active Desktop profile.
- * @param target - Empty transaction profile directory.
- */
-export function copyDesktopProfile(source: string, target: string): void {
-  const owned = readDesktopProfileState(source)?.links ?? []
-  for (const link of owned) {
-    const path = join(source, 'node_modules', link.name)
-    const entry = stat(path)
-    if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) {
-      throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`)
-    }
-  }
-  const links = new Set(owned.map(link => join(source, 'node_modules', link.name)))
-  cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true,
-    filter: path => !links.has(path) })
-}
-
 /**
  * Bind an external profile to this application's real package directories.
  * @param profile - Candidate profile.
@@ -191,10 +178,14 @@ export function validateDesktopPluginGraph(
   profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[],
 ): void {
   const profileRoot = realpathSync.native(profile)
-  const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))]))
+  const shared = new Map(runtime.sharedPackages.map((entry) => {
+    try { return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const }
+    catch (error) { throw new DesktopStartupError('reinstall', error) }
+  }))
   for (const [name, path] of shared) {
     if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
   }
+  if (activePlugins.length === 0) return
   const scanned = new Set<string>()
   const scan = (modules: string): void => {
     if (!existsSync(modules)) return
@@ -247,8 +238,12 @@ export function validateDesktopPluginGraph(
     }
   }
   for (const name of activePlugins) {
-    const path = packageFrom(profile, name)
-    if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
-    visit(path, name)
+    try {
+      const path = packageFrom(profile, name)
+      if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
+      visit(path, name)
+    } catch (error) {
+      throw new DesktopStartupError('plugins', error)
+    }
   }
 }

+ 79 - 140
apps/desktop/src/project-manager.ts

@@ -1,8 +1,7 @@
-/** Transactional owner of the reserved desktop profile and its private pnpm state. */
+/** In-place owner of the reserved desktop profile and its private pnpm state. */
 
 import { valid } from 'semver'
 import { spawn } from 'node:child_process'
-import { randomUUID } from 'node:crypto'
 import {
   existsSync,
   fsyncSync,
@@ -12,13 +11,13 @@ import {
   openSync,
   closeSync,
   readFileSync,
+  readdirSync,
   realpathSync,
-  renameSync,
   unlinkSync,
   writeFileSync,
   writeSync,
 } from 'node:fs'
-import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { delimiter, dirname, join, resolve, sep } from 'node:path'
 import {
   DESKTOP_HOST_PACKAGE,
   desktopCorePackageOverrides,
@@ -27,9 +26,10 @@ import {
 import type { DesktopPaths } from './paths.ts'
 import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
+import { DesktopStartupError } from './startup-error.ts'
 import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
-  copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
+  desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
   unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
 } from './profile-packages.ts'
 
@@ -53,16 +53,6 @@ interface DesktopProjectManifest {
   }
 }
 
-/** Journaled activation step used for crash recovery. */
-interface DesktopPendingTransaction {
-  readonly schemaVersion: 1
-  readonly id: string
-  readonly stagingProfile: string
-  readonly fromRuntimeId: string | null
-  readonly toRuntimeId: string
-  readonly step: 'prepared' | 'active-moved' | 'staging-activated'
-}
-
 /** Exact executables the desktop shell bundles. */
 export interface DesktopRuntimeExecutables {
   readonly node: string
@@ -70,12 +60,12 @@ export interface DesktopRuntimeExecutables {
   readonly dsh: string
 }
 
-/** Hooks that bind project replacement to the active backend lifecycle. */
+/** Hooks that stop the backend before profile writes and restart it after success. */
 export interface DesktopProjectHooks {
-  /** Stop the active backend and await process exit before directory moves. */
-  beforeActivate(): Promise<void>
-  /** Start the selected active project after commit or rollback. */
-  afterActivate(): Promise<void>
+  /** Stop the active backend and await process exit before modifying its files. */
+  beforeChange(): Promise<void>
+  /** Start the modified profile after package preparation succeeds. */
+  afterChange(): Promise<void>
 }
 
 /** Supported dependency mutation. */
@@ -124,11 +114,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null
 }
 
-function isDescendant(root: string, target: string): boolean {
-  const child = relative(root, target)
-  return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)
-}
-
 function assertPackageName(name: string): void {
   if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`)
 }
@@ -163,6 +148,11 @@ export function packageNameFromSpec(spec: string): string | undefined {
 }
 
 function projectManifest(projectDir: string): DesktopProjectManifest {
+  try { return readProjectManifest(projectDir) }
+  catch (error) { throw new DesktopStartupError('configuration', error) }
+}
+
+function readProjectManifest(projectDir: string): DesktopProjectManifest {
   const path = join(projectDir, 'package.json')
   const value = readJson(path)
   const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined
@@ -183,11 +173,11 @@ function projectManifest(projectDir: string): DesktopProjectManifest {
 function profilePluginNames(projectDir: string): readonly string[] {
   const bundles = projectManifest(projectDir).dsh.profile.bundles
   if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) {
-    throw new Error('desktop project: profile must begin with the built-in desktop bundle list')
+    throw new DesktopStartupError('configuration', new Error('desktop project: profile must begin with the built-in desktop bundle list'))
   }
   const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length)
   if (new Set(bundles).size !== bundles.length) {
-    throw new Error('desktop project: profile bundle list contains a duplicate package')
+    throw new DesktopStartupError('configuration', new Error('desktop project: profile bundle list contains a duplicate package'))
   }
   for (const plugin of plugins) assertPackageName(plugin)
   return plugins
@@ -234,7 +224,7 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin
   return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) }
 }
 
-/** Transactional desktop npm project manager. */
+/** Desktop npm project manager with direct writes and no rollback. */
 export class DesktopProjectManager {
   private lockDescriptor: number | undefined
   private descriptor: DesktopRuntimeDescriptor | undefined
@@ -248,50 +238,38 @@ export class DesktopProjectManager {
     readonly runtime: DesktopRuntimeExecutables,
   ) {}
 
-  /** Recover an interrupted directory replacement before reading the active project. */
-  recover(): void {
-    if (!existsSync(this.paths.pending)) return
-    const value = readJson(this.paths.pending)
-    if (!isRecord(value) || value.schemaVersion !== 1
-      || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string'
-      || !isDescendant(this.paths.staging, value.stagingProfile)
-      || value.stagingProfile !== join(this.paths.staging, value.id, 'profile')
-      || (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId)))
-      || typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId)
-      || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) {
-      throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`)
-    }
-    const pending: DesktopPendingTransaction = {
-      schemaVersion: 1,
-      id: value.id,
-      stagingProfile: value.stagingProfile,
-      fromRuntimeId: value.fromRuntimeId,
-      toRuntimeId: value.toRuntimeId,
-      step: value.step,
-    }
-    if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) {
-      if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) {
-        throw new Error('desktop project: activated profile does not match its journal')
-      }
-      removeOwnedDirectory(this.paths.profile)
-    }
-    if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) {
-      if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) {
-        throw new Error('desktop project: rollback profile does not match its journal')
-      }
-      mkdirSync(dirname(this.paths.profile), { recursive: true })
-      renameSync(this.paths.rollback, this.paths.profile)
-    }
-    removeOwnedDirectory(pending.stagingProfile)
-    unlinkSync(this.paths.pending)
-  }
-
   /** Read the active desktop plugin inventory. */
   listPlugins(): readonly DesktopPluginRecord[] {
     if (!existsSync(this.paths.profile)) return []
     return pluginRecords(this.paths.profile)
   }
 
+  /** @returns Whether the profile enables any third-party bundle, without loading plugin files. */
+  hasEnabledPlugins(): boolean {
+    return existsSync(this.paths.profile) && profilePluginNames(this.paths.profile).length > 0
+  }
+
+  /**
+   * Reinitialize the profile, deleting configuration and third-party packages without a backup.
+   * @param hooks - Stop the Host before resetting files; restart after preparation succeeds.
+   * @returns Completion of reset; the held lock and shared product data are preserved.
+   */
+  async resetConfiguration(hooks: DesktopProjectHooks): Promise<void> {
+    await this.withLock(async () => {
+      await hooks.beforeChange()
+      this.descriptor = this.readRuntime()
+      for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) {
+        const path = join(this.paths.profile, entry.name)
+        if (path === this.paths.lock) continue
+        if (entry.isDirectory()) removeOwnedDirectory(path)
+        else unlinkSync(path)
+      }
+      createPluginProfile(this.paths.profile)
+      this.prepareProfile(this.paths.profile)
+      await hooks.afterChange()
+    })
+  }
+
   /** Read the dsh version supplied by this application's verified resources. */
   dshVersion(): string {
     return this.currentRuntime().release.version
@@ -316,6 +294,11 @@ export class DesktopProjectManager {
     return this.descriptor
   }
 
+  private readRuntime(): DesktopRuntimeDescriptor {
+    try { return readDesktopRuntime(this.runtime.dsh) }
+    catch (error) { throw new DesktopStartupError('reinstall', error) }
+  }
+
   private prepareProfile(projectDir: string): void {
     const runtime = this.currentRuntime()
     linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
@@ -323,14 +306,13 @@ export class DesktopProjectManager {
   }
 
   /** Read release metadata and reconcile its external profile without installing core packages. */
-  async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise<boolean> {
+  async applyRelease(): Promise<boolean> {
     return this.withLock(async () => {
-      this.recover()
-      const target = readDesktopRuntime(this.runtime.dsh, electronVersion)
+      const target = this.readRuntime()
       this.descriptor = target
       const previous = readDesktopProfileState(this.paths.profile)
-      if (existsSync(this.paths.profile) && previous === undefined) {
-        throw new Error('desktop project: existing profile is not a Desktop plugin profile')
+      if (previous === undefined && readdirSync(this.paths.profile).some(name => join(this.paths.profile, name) !== this.paths.lock)) {
+        throw new DesktopStartupError('configuration', new Error('desktop project: existing profile is not a Desktop plugin profile'))
       }
       if (previous?.runtimeId === desktopRuntimeId(target)
         && previous.lockHash === desktopPluginLockHash(this.paths.profile)
@@ -341,37 +323,38 @@ export class DesktopProjectManager {
         validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile))
         return false
       }
-      const stagingProfile = this.newStagingProfile()
-      try {
-        if (previous === undefined) createPluginProfile(stagingProfile)
-        else copyDesktopProfile(this.paths.profile, stagingProfile)
-        await this.reconcileProfile(stagingProfile, previous)
-        await this.activate(stagingProfile, hooks)
-        return true
-      } catch (error) {
-        removeOwnedDirectory(stagingProfile)
-        throw error
-      }
+      if (previous === undefined) createPluginProfile(this.paths.profile)
+      await this.reconcileProfile(this.paths.profile, previous)
+      return true
     })
   }
 
-  /** Apply an exact plugin dependency or activation change through a staging project. */
+  /** Modify the current profile while its backend is stopped; failures retain partial changes. */
   async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
     await this.withLock(async () => {
-      this.recover()
       this.currentRuntime()
       if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
-      const stagingProfile = this.newStagingProfile()
+      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 previous = readDesktopProfileState(this.paths.profile)
+      const packagesChanged = mutation.type !== 'plugin-toggle'
+      if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile)
       try {
-        copyDesktopProfile(this.paths.profile, stagingProfile)
-        await this.applyMutation(stagingProfile, mutation)
-        await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile),
-          mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all')
-        await this.activate(stagingProfile, hooks)
-      } catch (error) {
-        removeOwnedDirectory(stagingProfile)
-        throw error
+        await this.applyMutation(this.paths.profile, mutation)
+      } finally {
+        if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime())
       }
+      await this.reconcileProfile(this.paths.profile, previous, packagesChanged)
+      await hooks.afterChange()
     })
   }
 
@@ -394,13 +377,7 @@ export class DesktopProjectManager {
     this.prepareProfile(projectDir)
   }
 
-  private newStagingProfile(): string {
-    const path = join(this.paths.staging, randomUUID(), 'profile')
-    mkdirSync(path, { recursive: true, mode: 0o700 })
-    return path
-  }
-
-  private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise<void> {
+  private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
     switch (mutation.type) {
       case 'plugin-add': {
         const requestedName = packageNameFromSpec(mutation.spec)
@@ -442,9 +419,6 @@ export class DesktopProjectManager {
           )
         }
         return
-      case 'plugins-disable-all':
-        writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false })))
-        return
       case 'plugin-toggle': {
         assertPackageName(mutation.name)
         const plugins = pluginRecords(projectDir)
@@ -459,42 +433,6 @@ export class DesktopProjectManager {
     }
   }
 
-  private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise<void> {
-    const pending: DesktopPendingTransaction = {
-      schemaVersion: 1,
-      id: basename(dirname(stagingProfile)),
-      stagingProfile,
-      fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null,
-      toRuntimeId: desktopRuntimeId(this.currentRuntime()),
-      step: 'prepared',
-    }
-    writeJson(this.paths.pending, pending)
-    let activeMoved = false
-    let stagingActivated = false
-    try {
-      await hooks.beforeActivate()
-      removeOwnedDirectory(this.paths.rollback)
-      mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 })
-      writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
-      if (existsSync(this.paths.profile)) {
-        renameSync(this.paths.profile, this.paths.rollback)
-        activeMoved = true
-      }
-      mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 })
-      writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction)
-      renameSync(stagingProfile, this.paths.profile)
-      stagingActivated = true
-      await hooks.afterActivate()
-      unlinkSync(this.paths.pending)
-    } catch (error) {
-      if (stagingActivated) removeOwnedDirectory(this.paths.profile)
-      if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile)
-      if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending)
-      await hooks.afterActivate().catch(() => undefined)
-      throw error
-    }
-  }
-
   private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
     const [command, ...commandArgs] = args
     if (command === undefined) throw new Error('desktop project: pnpm command is required')
@@ -586,7 +524,8 @@ export class DesktopProjectManager {
   }
 
   private async withLock<T>(operation: () => Promise<T>): Promise<T> {
-    mkdirSync(this.paths.root, { recursive: true, mode: 0o700 })
+    mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 })
+    if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link')
     let descriptor: number
     try {
       descriptor = openSync(this.paths.lock, 'wx', 0o600)

+ 27 - 37
apps/desktop/src/runtime-tree.ts

@@ -1,11 +1,11 @@
 /** Relocatable, integrity-recorded production packages carried by one Desktop release. */
 
 import { createHash } from 'node:crypto'
-import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs'
+import { lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs'
 import { isAbsolute, join, relative, sep } from 'node:path'
 import { promisify } from 'node:util'
 import { valid } from 'semver'
-import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts'
+import { DESKTOP_HOST_PACKAGE } from './core-package-set.ts'
 import { parseDesktopRelease, type DesktopRelease } from './release.ts'
 
 /** Descriptor at the root of the immutable Desktop resource tree. */
@@ -143,22 +143,21 @@ export function writeDesktopRuntime(
 }
 
 /**
- * Read release metadata and check shared manifests and Host entries without scanning runtime contents.
+ * Read packaged metadata and check shared package records.
  * @param root - Current application's runtime resources.
- * @param electronVersion - Expected shell version.
- * @param target - Required execution target; defaults to the current process.
- * @returns Validated runtime descriptor.
+ * @returns Runtime metadata whose release compatibility is verified during packaging.
  */
-export function readDesktopRuntime(
-  root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
-): DesktopRuntimeDescriptor {
+export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor {
   const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8'))
-  if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch
+  if (!record(value) || typeof value.platform !== 'string' || typeof value.arch !== 'string'
     || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) {
-    throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
+    throw new Error('desktop runtime: invalid descriptor')
   }
-  const release = parseDesktopRelease(value.release)
-  if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
+  if (!record(value.release) || typeof value.release.version !== 'string'
+    || typeof value.release.nodeVersion !== 'string' || typeof value.release.pnpmVersion !== 'string') {
+    throw new Error('desktop runtime: invalid release fields')
+  }
+  const release = value.release as unknown as DesktopRelease
   const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => {
     if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name)
       || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) {
@@ -169,34 +168,14 @@ export function readDesktopRuntime(
   if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) {
     throw new Error('desktop runtime: duplicate shared package')
   }
-  const files = value.files.map((entry: unknown): DesktopRuntimeFile => {
-    if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number'
-      || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean'
-      || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) {
-      throw new Error('desktop runtime: invalid file inventory')
-    }
-    runtimePath(root, entry.path)
-    return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable }
-  }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
-  for (const entry of sharedPackages) {
-    const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
-    if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
-      throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
-    }
-  }
+  const files = value.files as DesktopRuntimeFile[]
   for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) {
     if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) {
       throw new Error(`desktop runtime: missing or mismatched ${name}`)
     }
   }
-  for (const file of DESKTOP_HOST_RUNTIME_FILES) {
-    const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`
-    if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path))
-      || !lstatSync(runtimePath(root, path)).isFile()) {
-      throw new Error(`desktop runtime: missing Host file ${file}`)
-    }
-  }
-  return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files }
+  return { schemaVersion: value.schemaVersion as 1, release, platform: value.platform as NodeJS.Platform,
+    arch: value.arch, sharedPackages, files }
 }
 
 /**
@@ -209,7 +188,18 @@ export function readDesktopRuntime(
 export async function verifyDesktopRuntime(
   root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
 ): Promise<DesktopRuntimeDescriptor> {
-  const descriptor = readDesktopRuntime(root, electronVersion, target)
+  const descriptor = readDesktopRuntime(root)
+  if (descriptor.schemaVersion !== 1 || descriptor.platform !== target.platform || descriptor.arch !== target.arch) {
+    throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
+  }
+  const release = parseDesktopRelease(descriptor.release)
+  if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
+  for (const entry of descriptor.sharedPackages) {
+    const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
+    if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
+      throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
+    }
+  }
   const actual = await inventoryRuntimeForVerification(root)
   // Windows has no portable Unix executable permission bits.
   const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'

+ 25 - 0
apps/desktop/src/startup-document.ts

@@ -0,0 +1,25 @@
+/** Self-contained recovery document for an unavailable shell renderer or preload. */
+
+import type { DesktopLocale } from './locale.ts'
+
+/**
+ * Render escaped diagnostics without depending on application resource files.
+ * @param locale - Shell-owned translations.
+ * @param message - Failure details displayed as plain text.
+ * @returns An HTML document suitable for an isolated emergency window.
+ */
+export function startupFailureDocument(locale: DesktopLocale, message: string): string {
+  const escape = (value: string): string => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
+  return `<!doctype html><html lang="${locale.id}"><meta charset="utf-8">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action dsh-recovery:">
+<title>${escape(locale.messages.startupFailed)}</title>
+<style>:root{color-scheme:light dark;font-family:system-ui}body{max-width:720px;margin:10vh auto;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
+<main><h1>${escape(locale.messages.startupFailed)}</h1><p>${escape(locale.messages.startupReinstallAdvice)}</p>
+<p>${escape(locale.messages.startupConfigurationAdvice)}</p>
+<pre role="alert">${escape(message)}</pre>
+<form action="dsh-recovery://restart"><button>${escape(locale.messages.restartApplication)}</button></form>
+<form action="dsh-recovery://plugins"><button>${escape(locale.messages.disableThirdPartyPlugins)}</button></form>
+<form action="dsh-recovery://reset"><button>${escape(locale.messages.resetConfiguration)}</button></form>
+</main></html>`
+}

+ 31 - 0
apps/desktop/src/startup-error.ts

@@ -0,0 +1,31 @@
+/** Recovery guidance attached by the owner of a failed Desktop startup operation. */
+
+/** Actions that can restore an unavailable Desktop backend. */
+export type DesktopRecovery = 'restart' | 'reinstall' | 'plugins' | 'configuration'
+
+/** An operation failure with recovery guidance independent of its diagnostic wording. */
+export class DesktopStartupError extends Error {
+  /**
+   * @param recovery - Recovery supported by the failed operation.
+   * @param cause - Original diagnostic, retained for troubleshooting.
+   */
+  constructor(readonly recovery: DesktopRecovery, cause: unknown) {
+    super(cause instanceof Error ? cause.message : String(cause), { cause })
+  }
+}
+
+/**
+ * Preserve diagnostics and recovery guidance when sending failures to a renderer.
+ * @param error - Startup or runtime failure.
+ * @returns Serializable error state; unclassified failures suggest restarting.
+ */
+export function desktopErrorState(error: unknown): {
+  phase: 'error'
+  message: string
+  recovery: DesktopRecovery
+} {
+  const message = error instanceof AggregateError
+    ? [error.message, ...error.errors.map(item => desktopErrorState(item).message)].join('\n')
+    : error instanceof Error ? error.message : String(error)
+  return { phase: 'error', message, recovery: error instanceof DesktopStartupError ? error.recovery : 'restart' }
+}

+ 4 - 4
apps/desktop/tests/backend-controller.spec.ts

@@ -83,7 +83,7 @@ describe('desktop backend controller', () => {
   it('publishes preparation errors and permits retry', async () => {
     const f = fixture()
     await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile')
-    expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' })
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', recovery: 'restart' })
     expect(f.create).not.toHaveBeenCalled()
     f.ready.resolve()
     await f.controller.start(async () => {})
@@ -101,7 +101,7 @@ describe('desktop backend controller', () => {
     await f.stopping.promise
     f.exited.resolve()
     await rejected
-    expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' })
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', recovery: 'restart' })
     expect(f.host.stop).toHaveBeenCalledTimes(1)
     await f.controller.close()
   })
@@ -111,7 +111,7 @@ describe('desktop backend controller', () => {
     f.ready.resolve()
     await f.controller.start(async () => {})
     f.fail(new Error('transport failed'))
-    expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' })
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', recovery: 'restart' })
     expect(f.controller.host).toBeUndefined()
     await f.stopping.promise
     const prepare = vi.fn(async () => {})
@@ -135,7 +135,7 @@ describe('desktop backend controller', () => {
     await f.stopping.promise
     f.exited.resolve()
     await rejected
-    expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }])
+    expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', recovery: 'restart' }])
     await f.controller.close()
   })
 

+ 8 - 0
apps/desktop/tests/expected/startup-en-configuration.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness could not start
+Choose a recovery action below. Disabling third-party plugins retains their files.
+Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Failure details
+Close and restart
+Disable all third-party plugins and retry
+Reset Desktop and retry

+ 8 - 0
apps/desktop/tests/expected/startup-en-plugins.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness could not start
+Choose a recovery action below. Disabling third-party plugins retains their files.
+Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Failure details
+Close and restart
+Disable all third-party plugins and retry
+Reset Desktop and retry

+ 8 - 0
apps/desktop/tests/expected/startup-en-reinstall.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness could not start
+Choose a recovery action below. Disabling third-party plugins retains their files.
+Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Failure details
+Close and restart
+Disable all third-party plugins and retry
+Reset Desktop and retry

+ 8 - 0
apps/desktop/tests/expected/startup-en-restart.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness could not start
+Choose a recovery action below. Disabling third-party plugins retains their files.
+Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Failure details
+Close and restart
+Disable all third-party plugins and retry
+Reset Desktop and retry

+ 8 - 0
apps/desktop/tests/expected/startup-zh-CN-configuration.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness 无法启动
+请选择下方的恢复操作。禁用第三方插件会保留插件文件。
+重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+Failure details
+关闭并重启
+禁用全部第三方插件并重试
+重置 Desktop 并重试

+ 8 - 0
apps/desktop/tests/expected/startup-zh-CN-plugins.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness 无法启动
+请选择下方的恢复操作。禁用第三方插件会保留插件文件。
+重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+Failure details
+关闭并重启
+禁用全部第三方插件并重试
+重置 Desktop 并重试

+ 8 - 0
apps/desktop/tests/expected/startup-zh-CN-reinstall.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness 无法启动
+请选择下方的恢复操作。禁用第三方插件会保留插件文件。
+重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+Failure details
+关闭并重启
+禁用全部第三方插件并重试
+重置 Desktop 并重试

+ 8 - 0
apps/desktop/tests/expected/startup-zh-CN-restart.txt

@@ -0,0 +1,8 @@
+DeepSeek Harness 无法启动
+请选择下方的恢复操作。禁用第三方插件会保留插件文件。
+重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+Failure details
+关闭并重启
+禁用全部第三方插件并重试
+重置 Desktop 并重试

+ 133 - 5
apps/desktop/tests/main-startup.spec.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 const harness = await vi.hoisted(async () => {
@@ -12,6 +13,7 @@ const harness = await vi.hoisted(async () => {
   const windows: FakeWindow[] = []
   const hosts: FakeHost[] = []
   const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
+  let pluginsEnabled = false
   let preparing = deferred()
   let prepared = deferred()
   let hostStarted = deferred()
@@ -24,6 +26,7 @@ const harness = await vi.hoisted(async () => {
     readonly webContents = Object.assign(new EventEmitter(), {
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
+      getURL: () => this.urls.at(-1) ?? '',
       send: vi.fn((channel: string, state: { phase?: string }) => {
         if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve()
       }),
@@ -51,16 +54,18 @@ const harness = await vi.hoisted(async () => {
       this.ready.reject(new Error('child stopped'))
       return this.exited.promise
     })
-    constructor(..._args: unknown[]) { hosts.push(this) }
+    constructor(readonly node: string, readonly runtime: string, readonly profile: string) { hosts.push(this) }
   }
   const app = Object.assign(new EventEmitter(), {
-    isPackaged: false,
+    isPackaged: true,
     name: 'Desktop test',
     whenReady: () => Promise.resolve(),
     getLocale: () => 'en-US',
     getVersion: () => '1.0.0',
+    getAppPath: () => 'desktop-test-app',
     requestSingleInstanceLock: () => true,
     exit: vi.fn(),
+    relaunch: vi.fn(),
     quit: vi.fn(() => {
       const event = { preventDefault: vi.fn() }
       app.emit('before-quit', event)
@@ -71,12 +76,17 @@ const harness = await vi.hoisted(async () => {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
     dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
+    assertProfileRuntime: vi.fn(),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
     nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
+    get pluginsEnabled() { return pluginsEnabled },
+    set pluginsEnabled(value: boolean) { pluginsEnabled = value },
     reset() {
       windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
+      app.isPackaged = true
+      pluginsEnabled = false
       preparing = deferred(); prepared = deferred(); hostStarted = deferred()
       navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
     },
@@ -97,7 +107,16 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk
 vi.mock('../src/project-manager.ts', () => ({
   DesktopProjectManager: class {
     readonly applyRelease = harness.applyRelease
-    readonly assertProfileRuntime = vi.fn()
+    readonly assertProfileRuntime = harness.assertProfileRuntime
+    hasEnabledPlugins() { return harness.pluginsEnabled }
+    async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
+      await hooks.beforeChange()
+      harness.pluginsEnabled = false
+      await hooks.afterChange()
+    }
+    async resetConfiguration(hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
+      await this.mutate(undefined, hooks)
+    }
   },
 }))
 vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
@@ -117,7 +136,8 @@ beforeEach(() => {
   vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node')
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
-  vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
+  vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
+  vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
 })
 
 afterEach(async () => {
@@ -128,9 +148,98 @@ afterEach(async () => {
   vi.clearAllTimers()
   vi.useRealTimers()
   vi.unstubAllEnvs()
+  vi.unstubAllGlobals()
 })
 
 describe('desktop main startup', () => {
+  it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    const started = harness.nextHostStart()
+    const event = { preventDefault: vi.fn() }
+    window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`)
+    await harness.hosts[0]!.stopping.promise
+    harness.hosts[0]!.exited.resolve()
+    await started
+    harness.hosts[1]!.ready.resolve()
+    await harness.navigated.promise
+    expect(event.preventDefault).toHaveBeenCalled()
+    expect(window.urls.at(-1)).toBe('dsh-app://app/index.html')
+  })
+
+  it('allows a full profile reset for an unclassified startup failure', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.exited.resolve()
+    harness.hosts[0]!.ready.reject(new Error('Unknown startup failure'))
+    await harness.errorPublished.promise
+    const started = harness.nextHostStart()
+    const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset))
+    await started
+    harness.hosts[1]!.ready.resolve()
+    await reset
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
+  })
+
+  it('keeps a self-contained reinstall document in the main window after preload failure', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
+    expect(window.urls.at(-1)).toContain('data:text/html')
+    expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable')
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    expect(harness.windows).toHaveLength(1)
+    expect(window.urls.at(-1)).toContain('data:text/html')
+    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+  })
+
+  it('offers plugin recovery and disables plugins before restarting in the same window', async () => {
+    harness.pluginsEnabled = true
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.exited.resolve()
+    harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed'))
+    await harness.errorPublished.promise
+    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ recovery: 'plugins' })
+    const nextStarted = harness.nextHostStart()
+    const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll))
+    await nextStarted
+    expect(harness.pluginsEnabled).toBe(false)
+    harness.hosts[1]!.ready.resolve()
+    await recovery
+    expect(harness.windows).toHaveLength(1)
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
+  })
+
+  it('waits for Host exit before relaunching the application', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    harness.hosts[0]!.ready.resolve()
+    await harness.navigated.promise
+    const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart))
+    await harness.hosts[0]!.stopping.promise
+    expect(harness.app.relaunch).not.toHaveBeenCalled()
+    harness.hosts[0]!.exited.resolve()
+    await restart
+    expect(harness.app.relaunch).toHaveBeenCalledOnce()
+  })
+
   it('shows the loading window before profile preparation and starts one actual Host', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -148,12 +257,31 @@ describe('desktop main startup', () => {
     harness.hosts[0]!.ready.resolve()
     await Promise.all([retry, secondRetry, harness.navigated.promise])
     expect(harness.applyRelease).toHaveBeenCalledTimes(1)
+    expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile')
+    expect(harness.hosts[0]).toMatchObject({
+      node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'),
+      runtime: join('desktop-test-resources', 'dsh'),
+      profile: 'desktop-test-profile',
+    })
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html'])
     expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
   })
 
+  it('starts the unpackaged Host from the application development directory', async () => {
+    harness.app.isPackaged = false
+    await import('../src/main.ts')
+    await harness.hostStarted.promise
+    const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
+    expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: project })
+    expect(harness.applyRelease).not.toHaveBeenCalled()
+    expect(harness.assertProfileRuntime).not.toHaveBeenCalled()
+    harness.hosts[0]!.ready.resolve()
+    await harness.navigated.promise
+    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+  })
+
   it('keeps startup errors and a successful retry in the same window', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -165,7 +293,7 @@ describe('desktop main startup', () => {
     first.ready.reject(new Error('plugin composition failed'))
     await harness.errorPublished.promise
     await failedRetry
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' })
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', recovery: 'restart' })
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html'])
     const nextStarted = harness.nextHostStart()
     const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))

+ 2 - 2
apps/desktop/tests/plugin-pnpm.spec.ts

@@ -47,8 +47,8 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h
     const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
     writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`)
     const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
-    const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} }
-    await manager.applyRelease('1.0.0', hooks)
+    const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} }
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks)
     expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])
     const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string }

+ 5 - 0
apps/desktop/tests/preload-app.spec.ts

@@ -23,8 +23,13 @@ it('provides startup controls and a removable state subscription to shell docume
   await api.backend.status()
   await api.backend.retry()
   await api.openPlugins()
+  await api.disablePlugins()
+  await api.resetConfiguration()
+  await api.restart()
+  await api.close()
   expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
     [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen],
+    [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], [DESKTOP_IPC.applicationClose],
   ])
   const listener = vi.fn()
   const dispose = api.backend.subscribe(listener)

+ 3 - 6
apps/desktop/tests/profile-packages.spec.ts

@@ -1,11 +1,11 @@
 import { execFileSync } from 'node:child_process'
-import { mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, expect, it } from 'vitest'
 import { createPluginProfile } from '../src/project-manager.ts'
-import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
+import { linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
 import { runtimeFixture, writePackage } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -58,12 +58,9 @@ it('refuses to satisfy a plugin dependency from an ancestor CLI project', () =>
   writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } })
   expect(() =>{  validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u)
 })
-it('copies writable plugin bytes independently and removes broken owned links without following them', () => {
+it('removes broken owned links without following them', () => {
   const { root, profile } = fixture()
   writePackage(join(profile, 'node_modules'), 'plugin')
-  copyDesktopProfile(profile, join(root, 'copy'))
-  writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed')
-  expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity')
   rmSync(join(root, 'dsh'), { recursive: true })
   expect(() =>{  unlinkDesktopHostPackages(profile) }).not.toThrow()
 })

+ 132 - 56
apps/desktop/tests/project-manager.spec.ts

@@ -1,11 +1,10 @@
-import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
 import { resolveDesktopPaths } from '../src/paths.ts'
 import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts'
-import { readDesktopProfileState } from '../src/profile-packages.ts'
 import { runtimeFixture } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -48,7 +47,7 @@ if (command !== 'rebuild') {
   return path
 }
 function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
-  return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides }
+  return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
 }
 function setup(): { root: string; manager: DesktopProjectManager } {
   const root = temporaryRoot()
@@ -63,6 +62,85 @@ function calls(root: string): { args: string[]; registry: string }[] {
 afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
 
 describe('desktop external plugin profile', () => {
+  it('starts with disabled plugins even when their installed manifests are corrupt', async () => {
+    const { manager } = setup()
+    await manager.applyRelease()
+    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()).rejects.toThrow()
+    await manager.mutate({ type: 'plugins-disable-all' }, hooks())
+    await expect(manager.applyRelease()).resolves.toBe(false)
+    expect(readFileSync(manifest, 'utf8')).toBe('{broken')
+    await manager.resetConfiguration(hooks())
+    expect(existsSync(manifest)).toBe(false)
+    await expect(manager.applyRelease()).resolves.toBe(false)
+  })
+
+  it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
+    unlinkSync(patch)
+    await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => {
+      expect(manager.hasEnabledPlugins()).toBe(false)
+    } }))
+    expect(manager.hasEnabledPlugins()).toBe(false)
+    expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
+    expect(calls(root)).toHaveLength(2)
+    await expect(manager.applyRelease()).resolves.toBe(false)
+  })
+
+  it('resets the entire profile without backups while retaining its lock and shared data', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    const profile = manager.paths.profile
+    expect(manager.paths.lock).toBe(join(profile, 'lock'))
+    const task = join(root, '.dsh', 'task-sentinel')
+    const homeEnvironment = join(root, '.dsh', '.env')
+    writeFileSync(homeEnvironment, 'HOME_SETTING=retained')
+    writeFileSync(task, 'retained task')
+    writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken')
+    writeFileSync(join(profile, 'cordis.patch.yml'), ': broken')
+    writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad')
+    mkdirSync(join(profile, '.extra'))
+    writeFileSync(join(profile, '.extra', 'custom-file'), 'remove')
+    const shared = join(root, 'shared-data')
+    mkdirSync(shared)
+    writeFileSync(join(shared, 'sentinel'), 'preserve')
+    symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir')
+    await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' })
+    await manager.resetConfiguration(hooks({
+      beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
+      afterChange: async () => {
+        manager.assertProfileRuntime(profile)
+        expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid))
+        await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active')
+      },
+    }))
+    expect(manager.listPlugins()).toEqual([])
+    expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false)
+    expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false)
+    expect(existsSync(join(profile, '.env'))).toBe(false)
+    expect(existsSync(join(profile, '.extra'))).toBe(false)
+    expect(existsSync(join(profile, 'external-link'))).toBe(false)
+    expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve')
+    expect(readFileSync(task, 'utf8')).toBe('retained task')
+    expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained')
+    expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false)
+    expect(calls(root)).toHaveLength(2)
+    await expect(manager.applyRelease()).resolves.toBe(false)
+    expect(existsSync(homeEnvironment)).toBe(true)
+  })
+
+  it('reports damaged application metadata as a reinstall failure', async () => {
+    const { manager } = setup()
+    writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
+    await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' })
+  })
+
   it('accepts registry names and tags but rejects alternate sources and flags', () => {
     expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin')
     expect(packageNameFromSpec('plugin@next')).toBe('plugin')
@@ -73,9 +151,8 @@ describe('desktop external plugin profile', () => {
 
   it('initializes and restarts offline without executing pnpm', async () => {
     const { root, manager } = setup()
-    await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u)
-    await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
-    await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false)
+    await expect(manager.applyRelease()).resolves.toBe(true)
+    await expect(manager.applyRelease()).resolves.toBe(false)
     expect(manager.listPlugins()).toEqual([])
     expect(calls(root)).toEqual([])
     expect(existsSync(manager.paths.pnpm.store)).toBe(false)
@@ -85,21 +162,17 @@ describe('desktop external plugin profile', () => {
 
   it('repairs a removed managed link without running pnpm', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))
-    await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
+    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('1.0.0', hooks())
+    await manager.applyRelease()
     const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
-    let started = false
-    await expect(relaunched.applyRelease('1.0.0', hooks({
-      afterActivate: async () => { started = true },
-    }))).resolves.toBe(false)
-    expect(started).toBe(false)
+    await expect(relaunched.applyRelease()).resolves.toBe(false)
   })
 
   it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
@@ -108,22 +181,16 @@ 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'))
-    let starts = 0
-    await expect(manager.applyRelease('1.0.0', hooks({
-      afterActivate: async () => { starts++ },
-    }))).resolves.toBe(true)
+    await expect(manager.applyRelease()).resolves.toBe(true)
     const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
-    await expect(relaunched.applyRelease('1.0.0', hooks({
-      afterActivate: async () => { starts++ },
-    }))).resolves.toBe(false)
-    expect(starts).toBe(1)
+    await expect(relaunched.applyRelease()).resolves.toBe(false)
     expect(existsSync(manager.paths.profile)).toBe(true)
     expect(calls(root)).toEqual([])
   })
 
   it('installs only plugins and checks the graph before running lifecycle scripts', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
     expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }])
     expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
@@ -136,7 +203,7 @@ describe('desktop external plugin profile', () => {
 
   it('retains disabled plugin versions through updates and enables them explicitly', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     await manager.mutate({ type: 'plugins-disable-all' }, hooks())
     expect(calls(root)).toHaveLength(2)
@@ -151,30 +218,29 @@ describe('desktop external plugin profile', () => {
 
   it('keeps plugin files and patches through a compatible release and application relocation', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
     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('1.1.0', hooks())).resolves.toBe(true)
+    await expect(next.applyRelease()).resolves.toBe(true)
     expect(next.listPlugins()).toEqual(manager.listPlugins())
     expect(next.releaseVersion()).toBe('1.1.0')
     expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
     expect(calls(root)).toHaveLength(2)
     expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis')))
-    writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only')
     expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
   })
 
   it('reinstalls the locked plugin graph when bundled Node changes', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     const dsh = join(root, 'new-node')
     runtimeFixture(dsh, '1.1.0', '24.18.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
-    await next.applyRelease('1.1.0', hooks())
+    await next.applyRelease()
     expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
       ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'],
     ])
@@ -183,60 +249,70 @@ describe('desktop external plugin profile', () => {
 
   it('allows incompatible plugins to be disabled in recovery without deleting them', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     const dsh = join(root, 'next-major')
     runtimeFixture(dsh, '2.0.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
-    await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
-    expect(next.releaseVersion()).toBe('1.0.0')
-    expect(() =>{  next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u)
+    await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
+    expect(next.releaseVersion()).toBe('2.0.0')
     await next.mutate({ type: 'plugins-disable-all' }, hooks())
     expect(next.releaseVersion()).toBe('2.0.0')
     expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
   })
 
-  it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => {
+  it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
     const { manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     let starts = 0
     await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
-      beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') },
-      afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') },
+      beforeChange: async () => {
+        expect(manager.listPlugins()).toEqual([])
+        if (phase === 'before') throw new Error('before failed')
+      },
+      afterChange: async () => { starts++; throw new Error('after failed') },
     }))).rejects.toThrow(`${phase} failed`)
-    expect(manager.listPlugins()).toEqual([])
-    expect(existsSync(manager.paths.pending)).toBe(false)
+    expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
+    expect(starts).toBe(phase === 'before' ? 0 : 1)
+    expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false)
+    expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false)
+    expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false)
   })
 
-  it('recovers a directory move using recorded runtime identities', async () => {
-    const { manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
-    const id = readDesktopProfileState(manager.paths.profile)?.runtimeId
-    const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile')
-    mkdirSync(stagingProfile, { recursive: true })
-    mkdirSync(dirname(manager.paths.rollback), { recursive: true })
-    renameSync(manager.paths.profile, manager.paths.rollback)
-    writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' }))
-    manager.recover()
-    expect(manager.releaseVersion()).toBe('1.0.0')
-    expect(existsSync(stagingProfile)).toBe(false)
-    expect(existsSync(manager.paths.pending)).toBe(false)
+  it('keeps partial package changes and restores host links after pnpm fails', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    const failingPnpm = join(root, 'failing.mjs')
+    writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`)
+    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
+    await worker.applyRelease()
+    let starts = 0
+    await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
+      afterChange: async () => { starts++ },
+    }))).rejects.toThrow(/pnpm exited with 1/u)
+    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')))
+    await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
+    expect(manager.listPlugins()).toEqual([])
   })
 
   it('holds the transaction lock until the pnpm worker exits', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease('1.0.0', hooks())
+    await manager.applyRelease()
     const ready = join(root, 'ready')
     const release = join(root, 'release')
     const blocker = join(root, 'blocking.mjs')
     writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`)
     const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker })
-    await worker.applyRelease('1.0.0', hooks())
+    await worker.applyRelease()
     const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     try {
       await expect.poll(() => existsSync(ready)).toBe(true)
       expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
-      await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u)
+      await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
     } finally {
       writeFileSync(release, 'continue')
       await pending

+ 43 - 10
apps/desktop/tests/runtime-tree.spec.ts

@@ -23,34 +23,67 @@ it('verifies a runtime after relocation without depending on build paths', async
 })
 it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => {
   const dsh = join(fixture(), 'dsh')
-  const before = readDesktopRuntime(dsh, '1.0.0')
+  const before = readDesktopRuntime(dsh)
   if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}')
   if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n')
   if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '')
   if (operation === 'missing') rmSync(join(dsh, 'package.json'))
-  expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before)
+  expect(readDesktopRuntime(dsh)).toEqual(before)
   await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u)
 })
 it('rejects filesystem links and incompatible targets', async () => {
   const dsh = join(fixture(), 'dsh')
   await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u)
   symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir')
-  expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0')
+  expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0')
   await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u)
 })
-it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => {
+it('reads file inventory records unchanged during startup', () => {
+  const dsh = join(fixture(), 'dsh')
+  const path = join(dsh, DESKTOP_RUNTIME_FILE)
+  const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: unknown[] }
+  descriptor.files.unshift({ path: '../outside', bytes: -1.5, sha256: 'unchecked', executable: 'unchecked' })
+  writeFileSync(path, JSON.stringify(descriptor))
+  expect(readDesktopRuntime(dsh).files).toEqual(descriptor.files)
+})
+it.each(['missing', 'directory'])('checks a %s Host entry only during build verification', async (operation) => {
   const dsh = join(fixture(), 'dsh')
   const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0])
   rmSync(path)
   if (operation === 'directory') mkdirSync(path)
-  expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u)
+  expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0')
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u)
+})
+it('checks the shell version only during build verification', async () => {
+  const dsh = join(fixture(), 'dsh')
+  expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0')
+  await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/does not match Electron/u)
+})
+it.each([
+  { schemaVersion: 2 },
+  { platform: 'other' },
+  { arch: 'other' },
+  { release: { schemaVersion: 2 } },
+  { release: { hostProtocolVersion: 999 } },
+  { release: { nodeVersion: 'invalid' } },
+  { release: { pnpmVersion: 'invalid' } },
+])('checks release compatibility only during build verification: %j', async (patch) => {
+  const dsh = join(fixture(), 'dsh')
+  const path = join(dsh, DESKTOP_RUNTIME_FILE)
+  const original = readDesktopRuntime(dsh)
+  const descriptor = { ...original, ...patch, release: { ...original.release, ...patch.release } }
+  writeFileSync(path, JSON.stringify(descriptor))
+  expect(readDesktopRuntime(dsh)).toEqual(descriptor)
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/invalid|incompatible/u)
 })
-it('rejects incompatible targets and shared package metadata during startup metadata loading', () => {
+it.each(['missing', 'invalid-json', 'mismatched'])('checks %s shared manifests only during build verification', async (operation) => {
   const dsh = join(fixture(), 'dsh')
-  expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u)
-  expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u)
-  writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}')
-  expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u)
+  const before = readDesktopRuntime(dsh)
+  const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json')
+  if (operation === 'missing') rmSync(path)
+  else writeFileSync(path, operation === 'invalid-json' ? '{' : '{}')
+  expect(readDesktopRuntime(dsh)).toEqual(before)
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow()
 })
 it('rejects a descriptor that maps a shared package outside node_modules', async () => {
   const dsh = join(fixture(), 'dsh')

+ 62 - 22
apps/desktop/tests/startup-renderer.spec.ts

@@ -1,10 +1,12 @@
 import { readFileSync } from 'node:fs'
 import { runInContext } from 'node:vm'
+import { fileURLToPath } from 'node:url'
 import { JSDOM } from 'jsdom'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import type { DesktopBackendState } from '../src/backend-controller.ts'
 import type { DshDesktopStartupApi } from '../src/ipc.ts'
 import { resolveDesktopLocale } from '../src/locale.ts'
+import { startupFailureDocument } from '../src/startup-document.ts'
 
 function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.resolve({ phase: 'starting' })) {
   const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
@@ -16,6 +18,10 @@ function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.r
   const unsubscribe = vi.fn(() => { listeners.clear() })
   const retry = vi.fn(async () => {})
   const openPlugins = vi.fn(async () => {})
+  const disablePlugins = vi.fn(async () => {})
+  const resetConfiguration = vi.fn(async () => {})
+  const restart = vi.fn(async () => {})
+  const close = vi.fn(async () => {})
   const queried = Promise.withResolvers<undefined>()
   const api: DshDesktopStartupApi = {
     protocolVersion: 1,
@@ -26,6 +32,7 @@ function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.r
       subscribe: (listener) => { listeners.add(listener); return unsubscribe },
     },
     openPlugins,
+    disablePlugins, resetConfiguration, restart, close,
   }
   Object.defineProperty(dom.window, 'dshDesktop', { value: api })
   runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
@@ -42,12 +49,13 @@ function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.r
   }
   const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) }
   const copy = (): string => [element('#title').textContent, element('#description').textContent,
-    ...['#error', '#actions'].filter(selector => !element(selector).hidden)
+    ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden)
       .flatMap(selector => selector === '#actions'
-        ? [button('#retry').textContent, button('#plugins').textContent]
+        ? [...document.querySelectorAll<HTMLButtonElement>('#actions button')].filter(button => !button.hidden).map(button => button.textContent)
         : [element(selector).textContent]),
   ].join('\n')
-  return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise }
+  return { dom, document, element, button, publish, copy, retry, openPlugins,
+    disablePlugins, resetConfiguration, restart, close, unsubscribe, queried: queried.promise }
 }
 
 it('shows English loading and recovery actions without a Host document', async () => {
@@ -60,22 +68,23 @@ it('shows English loading and recovery actions without a Host document', async (
   expect(page.element('main').getAttribute('aria-busy')).toBe('true')
   expect(page.element('#spinner').hidden).toBe(false)
   expect(page.element('#actions').hidden).toBe(true)
-  expect(page.button('#retry').disabled).toBe(true)
-  expect(page.button('#plugins').disabled).toBe(true)
+  expect(page.button('#restart').disabled).toBe(true)
+  expect(page.button('#disable-plugins').disabled).toBe(true)
   page.publish({ phase: 'error', message: 'Plugin failed to load' })
   expect(page.copy()).toMatchInlineSnapshot(`
     "DeepSeek Harness could not start
-    The application could not start. Retry or manage Desktop plugins to resolve the problem.
+    Choose a recovery action below. Disabling third-party plugins retains their files.
+    Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
+    If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
     Plugin failed to load
-    Retry startup
-    Manage plugins"
+    Close and restart
+    Disable all third-party plugins and retry
+    Reset Desktop and retry"
   `)
   expect(page.element('main').getAttribute('aria-busy')).toBe('false')
   expect(page.element('#spinner').hidden).toBe(true)
-  page.button('#plugins').click()
-  expect(page.openPlugins).toHaveBeenCalledOnce()
-  page.button('#retry').click()
-  expect(page.retry).toHaveBeenCalledOnce()
+  page.button('#restart').click()
+  expect(page.restart).toHaveBeenCalledOnce()
   expect(page.element('#actions').hidden).toBe(true)
   expect(page.element('#error').textContent).toBe('')
   expect(page.element('main').getAttribute('aria-busy')).toBe('true')
@@ -92,10 +101,13 @@ it('shows Chinese loading and recovery copy', async () => {
   page.publish({ phase: 'error', message: '插件加载失败' })
   expect(page.copy()).toMatchInlineSnapshot(`
     "DeepSeek Harness 无法启动
-    应用未能启动。你可以重试,或管理桌面插件以解决问题。
+    请选择下方的恢复操作。禁用第三方插件会保留插件文件。
+    重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
+    如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
     插件加载失败
-    重试启动
-    管理插件"
+    关闭并重启
+    禁用全部第三方插件并重试
+    重置 Desktop 并重试"
   `)
 })
 
@@ -106,14 +118,11 @@ it('renders diagnostic markup as text and exposes failures from recovery actions
   page.publish({ phase: 'error', message: diagnostic })
   expect(page.element('#error').textContent).toBe(diagnostic)
   expect(page.element('#error').childElementCount).toBe(0)
-  page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager'))
-  page.button('#plugins').click()
-  await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager')
-  page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed'))
-  page.button('#retry').click()
+  page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed'))
+  page.button('#restart').click()
   await expect.poll(() => page.element('#error').textContent).toBe('Retry failed')
-  expect(page.button('#retry').disabled).toBe(false)
-  expect(page.button('#plugins').disabled).toBe(false)
+  expect(page.button('#restart').disabled).toBe(false)
+  expect(page.button('#disable-plugins').disabled).toBe(false)
 })
 
 it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => {
@@ -132,3 +141,34 @@ it('keeps subscribed state when initial status arrives late and detaches on page
   page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide'))
   expect(page.unsubscribe).toHaveBeenCalledOnce()
 })
+
+it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => {
+  const page = startup(locale)
+  await expect.poll(() => page.button('#restart').disabled).toBe(true)
+  for (const recovery of ['plugins', 'configuration', 'reinstall', 'restart'] as const) {
+    page.publish({ phase: 'error', recovery, message: 'Failure details' })
+    await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-${recovery}.txt`, import.meta.url)))
+    expect(page.button('#disable-plugins').hidden).toBe(false)
+    expect(page.button('#reset-configuration').hidden).toBe(false)
+    const action = recovery === 'plugins' ? '#disable-plugins' : recovery === 'configuration'
+      ? '#reset-configuration' : '#restart'
+    page.button(action).click()
+    expect(page.element('#actions').hidden).toBe(true)
+  }
+  expect(page.disablePlugins).toHaveBeenCalledOnce()
+  expect(page.resetConfiguration).toHaveBeenCalledOnce()
+  expect(page.close).not.toHaveBeenCalled()
+  expect(page.restart).toHaveBeenCalledTimes(2)
+})
+
+it('keeps emergency diagnostics inert without shell assets', () => {
+  const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '<script>alert(1)</script>')
+  const dom = new JSDOM(html)
+  expect(dom.window.document.querySelector('script')).toBeNull()
+  expect(dom.window.document.querySelector('pre')?.textContent).toBe('<script>alert(1)</script>')
+  expect(dom.window.document.querySelector('p')?.textContent).toContain('重新安装')
+  expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual([
+    'dsh-recovery://restart', 'dsh-recovery://plugins', 'dsh-recovery://reset',
+  ])
+  dom.window.close()
+})