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

Merge pull request #4263 from deepseek-harness/desktop/native-fatal-recovery

feat(desktop): use native dialogs for fatal recovery
07akioni 3 недель назад
Родитель
Сommit
6f893f270a
67 измененных файлов с 1085 добавлено и 1039 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  5. 3 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  6. 3 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  8. 3 3
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  9. 3 3
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  11. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  12. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml
  14. 27 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
  15. 27 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  17. 2 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  18. 2 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  19. 2 2
      apps/desktop/README.i18n.yaml
  20. 15 16
      apps/desktop/README.md
  21. 15 16
      apps/desktop/README.zh.md
  22. 1 0
      apps/desktop/package.json
  23. 0 6
      apps/desktop/renderer/plugin-manager.html
  24. 0 8
      apps/desktop/renderer/plugin-manager.js
  25. 0 16
      apps/desktop/renderer/startup.css
  26. 0 26
      apps/desktop/renderer/startup.html
  27. 0 45
      apps/desktop/renderer/startup.js
  28. 1 1
      apps/desktop/scripts/dev.ts
  29. 3 10
      apps/desktop/scripts/smoke-windows.ps1
  30. 1 1
      apps/desktop/src/backend-controller.ts
  31. 73 0
      apps/desktop/src/fatal-recovery.ts
  32. 1 21
      apps/desktop/src/ipc.ts
  33. 14 22
      apps/desktop/src/locale.ts
  34. 57 136
      apps/desktop/src/main.ts
  35. 0 26
      apps/desktop/src/owned-directory.ts
  36. 7 22
      apps/desktop/src/preload-app.ts
  37. 0 11
      apps/desktop/src/preload.ts
  38. 91 0
      apps/desktop/src/profile-core-cleanup.ts
  39. 22 31
      apps/desktop/src/project-manager.ts
  40. 0 26
      apps/desktop/src/startup-document.ts
  41. 1 1
      apps/desktop/tests/development-project.spec.ts
  42. 9 0
      apps/desktop/tests/expected/fatal-dialog-en.txt
  43. 9 0
      apps/desktop/tests/expected/fatal-dialog-zh-CN.txt
  44. 0 8
      apps/desktop/tests/expected/startup-en-profile.txt
  45. 0 5
      apps/desktop/tests/expected/startup-en-restart.txt
  46. 0 8
      apps/desktop/tests/expected/startup-zh-CN-profile.txt
  47. 0 5
      apps/desktop/tests/expected/startup-zh-CN-restart.txt
  48. 104 0
      apps/desktop/tests/fatal-recovery.spec.ts
  49. 0 29
      apps/desktop/tests/fixtures/owned-directory-smoke.mjs
  50. 140 152
      apps/desktop/tests/main-startup.spec.ts
  51. 0 53
      apps/desktop/tests/owned-directory.spec.ts
  52. 4 12
      apps/desktop/tests/plugin-manager.spec.ts
  53. 1 1
      apps/desktop/tests/plugin-pnpm.spec.ts
  54. 5 27
      apps/desktop/tests/preload-app.spec.ts
  55. 22 0
      apps/desktop/tests/primary-runtime-preparation.spec.ts
  56. 106 0
      apps/desktop/tests/profile-core-cleanup.spec.ts
  57. 69 55
      apps/desktop/tests/project-manager.spec.ts
  58. 0 175
      apps/desktop/tests/startup-renderer.spec.ts
  59. 29 19
      apps/web/src/main.ts
  60. 64 0
      apps/web/tests/desktop-boot.spec.ts
  61. 2 2
      packages/client/web/README.i18n.yaml
  62. 1 1
      packages/client/web/README.md
  63. 1 1
      packages/client/web/README.zh.md
  64. 8 4
      packages/client/web/src/boot.ts
  65. 47 0
      packages/client/web/tests/boot.client.spec.ts
  66. 13 21
      pnpm-lock.yaml
  67. 1 0
      pnpm-workspace.yaml

+ 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: 461f82719fabba67bd93c4c1f2a37d02fac1c06b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: efbeeb20993fdc6baeb11ca1ea1d1af5501567dc
+2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae

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

@@ -14,7 +14,7 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 Electron creates the main window with the packaged Web loading page before profile reconciliation or Host startup. The Web entry draws its boot page before awaiting Host readiness. The owned preload delivers structured boot injections, and the existing document activates its client plugins after they are applied; startup failures display diagnostics and available recovery actions. 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. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available with loaded runtime metadata and available resources, including in development mode. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
+Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here.
 
 Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 

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

@@ -14,7 +14,7 @@ profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in
 
 Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主窗口。Web 入口先显示启动页,再等待 Host 就绪。自有 preload 交付结构化启动注入,现有文档应用注入后激活客户端插件;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
-主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。加载了运行时元数据且资源可用时,包括开发模式,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
+致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定。
 
 Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: 425145ad89f9697c2420544b0ce2e70759313ffc
-2026-09-09-desktop-in-place-profile.zh.md: 8b98ec1c2e426b0019115a9a19eb84db4b9caef8
+2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
+2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd

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

@@ -10,13 +10,15 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
+Application-owned package retention is qualified by the [production cleanup decision](../bug-fix/2026-09-15-desktop-profile-core-cleanup.md).
+
 Desktop stops the Host and modifies the current profile directly. Shared app-boot cleanup detaches its own fallback links before package changes; the Host’s shared profile runner supplies required links on startup. Package locking and configured lifecycle scripts remain. Upgrades refresh module links without copying plugin files.
 
 Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.
 
 This supersedes staging and rollback in [the packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md), [the bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), and [the immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md). Host boot follows the [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md); release, module ownership, and window lifecycle remain separate decisions.
 
-Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, reset, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
+Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
 
 ## Alternatives considered
 

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

@@ -10,13 +10,15 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
+应用管理包的保留范围受[生产清理决策](../bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md)限定。
+
 Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
 
 本记录取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)、[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)及[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的暂存与回滚。Host 启动遵循[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md);发布、模块归属与窗口生命周期仍由各自决策负责。
 
-Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除、重置和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
+Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 5b8df22e520af752ac1b8fefbf2cf2442ce17eca
-2026-09-10-desktop-web-wrapper.zh.md: 44c79d8c914f0b2bdeb69dbeb09f22ce84efe9c8
+2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac
+2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404

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

@@ -14,7 +14,7 @@ Separate Desktop composition and request transport require their own configurati
 
 The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
 
-The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and patch-reload policy and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
 
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 
@@ -38,8 +38,8 @@ This partially supersedes the private composition and portless transport in the
 
 ## Consequences
 
-Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; independent recovery resources remain available when startup fails.
+Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails.
 
-User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the independent recovery UI rather than triggering stricter admission checks or automatic rollback.
+User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the native recovery dialog rather than triggering stricter admission checks or automatic rollback.
 
 Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.

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

@@ -14,7 +14,7 @@ Status: implemented
 
 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
 
-共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表和 patch 重载策略初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 
@@ -38,8 +38,8 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 ## Consequences
 
-Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
 
-用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
 
 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.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-15-desktop-native-fatal-recovery.md
+2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
+2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -0,0 +1,27 @@
+# Agent Note: Native Desktop fatal recovery
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-native-fatal-recovery.zh.md)
+
+## Problem
+
+A recovery document depends on the renderer and preload whose failure can prevent application startup. Multiple reports from one failed startup can also obscure the original diagnostic and interrupt recovery.
+
+## Decision
+
+Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
+
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+
+The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
+
+This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes.
+
+## Alternatives considered
+
+A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
+
+## Consequences
+
+Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 原生 Desktop 致命错误恢复
+
+Status: implemented
+
+[English](2026-09-15-desktop-native-fatal-recovery.md) | 中文
+
+## Problem
+
+恢复文档依赖渲染器和 preload,而这些组件的故障本身就可能阻止应用启动。同一次启动失败的多个报告还可能掩盖原始诊断并打断恢复操作。
+
+## Decision
+
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+
+Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
+
+这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
+
+## Alternatives considered
+
+Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
+
+## Consequences
+
+恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.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/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
+2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
+2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md

@@ -0,0 +1,27 @@
+# Agent Note: Clean application-owned packages before Desktop production boot
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-profile-core-cleanup.zh.md)
+
+## Problem
+
+Old Desktop profiles contain installed core packages and local tarball dependency declarations. Local package precedence can combine an old Web frontend with new plugins even when the application carries a consistent release. Development fallback links also remain when users switch to an installed application.
+
+## Decision
+
+Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
+
+The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
+
+## Alternatives considered
+
+**Installer-only cleanup** misses other user profiles and packages recreated after installation; both platforms use startup cleanup.
+
+**Deleting every organization-prefixed package** can remove optional official plugins. Explicit current and historical package inventories determine ownership.
+
+**Deleting package directories alone** lets pnpm reinstall the same old packages from retained declarations and overrides.
+
+## Consequences
+
+Production loses profile-local overrides of application-owned packages. A changed profile loses its lockfile and the next pnpm operation resolves remaining plugin dependencies again. Cleanup does not run pnpm or create rollback state; failures stop preparation and can be retried. Redirected package-parent directories fail before deletion. Focused tests cover retained plugins, declarations, repeated cleanup, development exclusion, retired packages, invalid records, and link targets.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 在 Desktop 生产启动前清理应用管理的包
+
+Status: implemented
+
+[English](2026-09-15-desktop-profile-core-cleanup.md) | 中文
+
+## Problem
+
+旧 Desktop profile 包含已安装的核心包及本地 tarball 依赖声明。即使应用携带一致的发布产物,本地包优先规则仍可能把旧 Web 前端与新插件组合起来。用户切换到已安装应用时,开发模式的回退链接也会残留。
+
+## Decision
+
+生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
+
+实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
+
+## Alternatives considered
+
+**仅在安装器中清理**会遗漏其他用户的 profile 和安装后重新产生的包;两个平台都在启动时清理。
+
+**删除组织名前缀下的所有包**可能误删可选官方插件。明确的当前及历史包清单决定归属。
+
+**仅删除包目录**会让 pnpm 根据保留的声明和 overrides 重新安装相同旧包。
+
+## Consequences
+
+生产版不再使用 profile 对应用管理包的本地覆盖。发生变化的 profile 丢弃锁文件,下次 pnpm 操作会重新解析其余插件依赖。清理不运行 pnpm,也不创建回滚状态;失败会停止准备流程,之后可以重试。重定向的包父目录会在删除前报错。定向测试覆盖插件保留、声明、重复清理、开发模式排除、退役包、无效记录及链接目标。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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/feature/2026-09-14-desktop-primary-runtime.md
-2026-09-14-desktop-primary-runtime.md: 54dfba5d786b0e557f3e52b104a89e53fb091ecb
-2026-09-14-desktop-primary-runtime.zh.md: f57f2dbf5efdda9c09aefa3fd78cb9f0f2a7b8ea
+2026-09-14-desktop-primary-runtime.md: 17d489e6c788c786cefcdddf5f1983ac48522bfe
+2026-09-14-desktop-primary-runtime.zh.md: f8be37801452ebf4f4623a1ad0342de770e21ad5

+ 2 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md

@@ -18,6 +18,8 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks these
 
 macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
 
+Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
+
 ## Alternatives considered
 
 **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas.

+ 2 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md

@@ -18,6 +18,8 @@ Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压
 
 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
 
+Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
+
 ## Alternatives considered
 
 **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。

+ 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: 2793dae3a6bd0ca9ce5b4a7c13e539a861741994
-README.zh.md: 2beb07eb7580154a3aafb6fbea37157529e8d4f8
+README.md: 9075e86b0817853b28b28aa1cee45f35904a38d0
+README.zh.md: c25cc17a89ff65a7deedceb78778651b2d4a5c1f

+ 15 - 16
apps/desktop/README.md

@@ -20,16 +20,15 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
 
 This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
 
-Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies.
+Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
 
 | Decision | Why | Direct consequence |
 |---|---|---|
 | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
 | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
 | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
-| Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
-| Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
+| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. |
 | 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 and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
@@ -39,11 +38,11 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
-The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles and patch-reload policy.
+The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
 
 Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
 
@@ -51,21 +50,21 @@ Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-al
 
 The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's 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 the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
-2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
+1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
+2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
-5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
+5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
 
-Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
+Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
+Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
 
-Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
+Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 
@@ -119,10 +118,10 @@ Production packages first pass through npm's publication rules and dependency in
 
 The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 
-Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
+Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
@@ -212,7 +211,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime.
+A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
 
 Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 

+ 15 - 16
apps/desktop/README.zh.md

@@ -20,16 +20,15 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的
 
 该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
 
-Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。
+Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
 
 | 决策 | 原因 | 直接结果 |
 |---|---|---|
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
-| 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
-| 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
+| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
@@ -39,11 +38,11 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权。
+应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
-产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表和 patch 重载策略初始化新 profile。
+产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。
 
 Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
 
@@ -51,21 +50,21 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
-2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
+1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
+2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
-5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
+5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
 
-Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
+原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
+恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
 
-包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表。
+包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 
 ## 开发
 
@@ -119,10 +118,10 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 
-Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
+Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
@@ -212,7 +211,7 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时。
+打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
 
 签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 

+ 1 - 0
apps/desktop/package.json

@@ -48,6 +48,7 @@
     "electron": "^44.0.0",
     "electron-builder": "^26.15.3",
     "extract-zip": "^2.0.1",
+    "fflate": "^0.8.2",
     "js-yaml": "^4.2.0",
     "pnpm": "11.7.0",
     "tar": "^7.5.0",

+ 0 - 6
apps/desktop/renderer/plugin-manager.html

@@ -16,12 +16,6 @@
         </div>
         <button id="refresh" class="quiet" type="button"></button>
       </header>
-      <section id="recovery" hidden>
-        <p id="recovery-description"></p>
-        <p id="startup-error" role="alert"></p>
-        <button id="retry" type="button"></button>
-        <button id="disable-all" type="button"></button>
-      </section>
       <form id="install-form">
         <label id="package-label" for="package-spec"></label>
         <div class="install-row">

+ 0 - 8
apps/desktop/renderer/plugin-manager.js

@@ -14,9 +14,6 @@ async function main() {
   document.querySelector('#installed-heading').textContent = messages.installed
   document.querySelector('#empty').textContent = messages.noPlugins
 
-  document.querySelector('#recovery-description').textContent = messages.recoveryDescription
-  document.querySelector('#retry').textContent = messages.retry
-  document.querySelector('#disable-all').textContent = messages.disableAll
 
   const list = document.querySelector('#plugins')
   const empty = document.querySelector('#empty')
@@ -37,9 +34,6 @@ async function main() {
   }
 
   async function render() {
-    const backend = await api.backend.status()
-    document.querySelector('#recovery').hidden = backend.phase !== 'error'
-    document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : ''
     const plugins = await api.plugins.list()
     list.replaceChildren(...plugins.map(plugin => {
       const item = document.createElement('li')
@@ -128,8 +122,6 @@ async function main() {
     updateForm.hidden = true
     updateTrigger.focus()
   })
-  document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
-  document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
 
   await load(messages.loadingPlugins, '')

+ 0 - 16
apps/desktop/renderer/startup.css

@@ -1,16 +0,0 @@
-:root { color-scheme: light dark; font-family: system-ui, sans-serif; color: #202124; background: #fafafa; }
-body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
-main { width: min(560px, calc(100vw - 64px)); padding: 40px 0; text-align: center; }
-h1 { margin: 24px 0 12px; font-size: 22px; font-weight: 600; }
-p { color: #666; line-height: 1.6; }
-#spinner { width: 32px; height: 32px; margin: auto; border: 3px solid #dedee3; border-top-color: #4d6bfe; border-radius: 50%; animation: spin 0.9s linear infinite; }
-#error { padding: 16px; border: 1px solid #e0e0e5; border-radius: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; font: 13px/1.6 ui-monospace, monospace; }
-#actions { margin-top: 24px; }
-button { margin: 4px; padding: 10px 18px; border: 1px solid transparent; border-radius: 8px; color: #fff; background: #4d6bfe; font: inherit; cursor: pointer; }
-button.secondary { color: inherit; background: transparent; border-color: #c8c8d0; }
-button:disabled { opacity: 0.5; cursor: default; }
-button:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
-[hidden] { display: none !important; }
-@keyframes spin { to { transform: rotate(360deg); } }
-@media (prefers-color-scheme: dark) { :root { color: #ededf0; background: #171719; } p { color: #aaaab3; } #error { border-color: #38383f; } #spinner { border-color: #38383f; border-top-color: #6a85ff; } }
-@media (prefers-reduced-motion: reduce) { #spinner { animation: none; } }

+ 0 - 26
apps/desktop/renderer/startup.html

@@ -1,26 +0,0 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
-    <title id="page-title"></title>
-    <link rel="stylesheet" href="startup.css">
-  </head>
-  <body>
-    <main aria-busy="true">
-      <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="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>
-  </body>
-</html>

+ 0 - 45
apps/desktop/renderer/startup.js

@@ -1,45 +0,0 @@
-const api = window.dshDesktop
-
-async function main() {
-  const { id, messages } = await api.locale()
-  document.documentElement.lang = id
-  document.querySelector('#page-title').textContent = messages.startupLoading
-  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))
-    document.querySelector('#spinner').hidden = failed
-    document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading
-    document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription
-    document.querySelector('#error').hidden = !failed
-    document.querySelector('#error').textContent = failed ? state.message : ''
-    document.querySelector('#actions').hidden = !failed
-    for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
-    for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) {
-      document.querySelector(selector).hidden = !failed || !state.profileRecovery
-    }
-    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)
-  async function recover(operation) {
-    render({ phase: 'starting' })
-    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()

+ 1 - 1
apps/desktop/scripts/dev.ts

@@ -113,7 +113,7 @@ async function main(): Promise<void> {
   await launchElectron()
 }
 
-main().catch((error: unknown) => {
+await main().catch((error: unknown) => {
   console.error(error instanceof Error ? error.message : error)
   process.exitCode = 1
 })

+ 3 - 10
apps/desktop/scripts/smoke-windows.ps1

@@ -1,14 +1,13 @@
-# Run native Electron cleanup and NSIS replacement checks against the prepared Windows target.
+# Run NSIS directory and replacement checks against the prepared Windows target.
 param(
-  [Parameter(Mandatory)][string]$Electron,
   [Parameter(Mandatory)][string]$Makensis,
   [Parameter(Mandatory)][string]$SevenZip,
   [Parameter(Mandatory)][string]$PluginDir
 )
 $ErrorActionPreference = 'Stop'
 $desktopRoot = Split-Path $PSScriptRoot -Parent
-$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 $fixtureRoot = Join-Path $desktopRoot 'tests/fixtures'
+$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 
 function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
   & $Executable @Arguments | Out-Host
@@ -17,12 +16,6 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
 
 try {
   & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
-  $previousRunAsNode = $env:ELECTRON_RUN_AS_NODE
-  try {
-    $env:ELECTRON_RUN_AS_NODE = '1'
-    Invoke-Checked $electron @((Join-Path $fixtureRoot 'owned-directory-smoke.mjs'))
-  } finally { $env:ELECTRON_RUN_AS_NODE = $previousRunAsNode }
-
   $payload = Join-Path $scratch 'payload'
   New-Item -ItemType Directory -Path $payload | Out-Null
   [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
@@ -49,7 +42,7 @@ try {
       throw "Unexpected NSIS $mode replacement result"
     }
   }
-  Write-Output 'Electron cleanup and NSIS directory/replacement smokes passed.'
+  Write-Output 'NSIS directory/replacement smokes passed.'
 } finally {
   $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
   $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)

+ 1 - 1
apps/desktop/src/backend-controller.ts

@@ -3,7 +3,7 @@
 import { desktopErrorState } 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; readonly profileRecovery?: boolean }
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string }
 
 /** Child lifecycle owned by the desktop backend controller. */
 export interface DesktopBackendHost {

+ 73 - 0
apps/desktop/src/fatal-recovery.ts

@@ -0,0 +1,73 @@
+/** Native recovery for the first fatal failure in one Desktop process. */
+
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import type { DesktopMessages } from './locale.ts'
+import { desktopErrorState } from './startup-error.ts'
+
+interface RecoveryOperations {
+  messages(): DesktopMessages
+  show(options: MessageBoxOptions): Promise<MessageBoxReturnValue>
+  stop(): Promise<void>
+  disablePlugins(): Promise<void>
+  exit(): void
+  restart(): void
+}
+
+function dialogDetail(error: string, messages: DesktopMessages): string {
+  const advice = `\n\n${messages.startupReinstallAdvice}`
+  const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-8).join('\n')
+  const budget = 1200 - advice.length - messages.diagnosticTruncated.length - 1
+  const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '')
+  return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${advice}`
+}
+
+/** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */
+export class DesktopFatalRecovery {
+  private reported = false
+
+  /** @param operations - Native presentation and application-owned shutdown operations. */
+  constructor(private readonly operations: RecoveryOperations) {}
+
+  /** Whether this process requires a native recovery action before further plugin changes. */
+  get active(): boolean { return this.reported }
+
+  /**
+   * Show the first fatal error; later reports cannot replace it or open another dialog.
+   * @param error - Fatal failure, including nested diagnostic causes.
+   * @returns Completion of the user's recovery action; duplicate reports resolve immediately.
+   */
+  async report(error: unknown): Promise<void> {
+    if (this.reported) return
+    this.reported = true
+    const messages = this.operations.messages()
+    let detail = desktopErrorState(error).message
+    let message = messages.fatalSummary
+    for (;;) {
+      const { response } = await this.operations.show({
+        type: 'error',
+        title: messages.startupFailed,
+        message,
+        detail: dialogDetail(detail, messages),
+        buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
+        defaultId: 1,
+        cancelId: 0,
+        noLink: true,
+      })
+      if (response === 0) {
+        try { await this.operations.stop() } catch (failure) { console.error(failure) }
+        this.operations.exit()
+        return
+      }
+      try {
+        await this.operations.stop()
+        if (response === 2) await this.operations.disablePlugins()
+        this.operations.restart()
+        return
+      } catch (failure) {
+        console.error(failure)
+        message = messages.recoveryOperationFailed
+        detail = desktopErrorState(failure).message
+      }
+    }
+  }
+}

+ 1 - 21
apps/desktop/src/ipc.ts

@@ -2,23 +2,17 @@
 
 import type { DesktopPluginRecord } from './project-manager.ts'
 import type { DesktopLocale } from './locale.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
 
 /** IPC channel names kept private to the desktop application bundle. */
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
+  bootFailed: 'dsh-desktop:boot-failed',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
   pluginsUpdate: 'dsh-desktop:plugins-update',
   pluginsToggle: 'dsh-desktop:plugins-toggle',
-  pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
-  backendStatus: 'dsh-desktop:backend-status',
-  backendRetry: 'dsh-desktop:backend-retry',
-  applicationRestart: 'dsh-desktop:application-restart',
-  configurationReset: 'dsh-desktop:configuration-reset',
-  backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
   updatesState: 'dsh-desktop:updates-state',
@@ -42,12 +36,6 @@ export interface DshDesktopApi {
     remove(name: string): Promise<void>
     update(name: string, version: string): Promise<void>
     toggle(name: string, enabled: boolean): Promise<void>
-    disableAll(): Promise<void>
-  }
-  readonly backend: {
-    status(): Promise<DesktopBackendState>
-    retry(): Promise<void>
-    subscribe(listener: (state: DesktopBackendState) => void): () => void
   }
   readonly updates: {
     check(): Promise<DesktopUpdateState>
@@ -55,11 +43,3 @@ export interface DshDesktopApi {
     subscribe(listener: (state: DesktopUpdateState) => void): () => void
   }
 }
-
-/** Startup-page controls, unavailable to backend-provided application documents. */
-export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale'> {
-  readonly backend: Omit<DshDesktopApi['backend'], 'retry'>
-  disablePlugins(): Promise<void>
-  restart(): Promise<void>
-  resetConfiguration(): Promise<void>
-}

+ 14 - 22
apps/desktop/src/locale.ts

@@ -2,15 +2,14 @@
 
 export const en = {
   application: 'Application',
-  startupFailed: 'DeepSeek Harness could not start',
-  startupLoading: 'Starting DeepSeek Harness…',
-  startupLoadingDescription: 'Your workspace will open when it is ready.',
-  startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.',
+  startupFailed: 'DeepSeek Harness is unavailable',
+  fatalSummary: 'The application could not start or stopped unexpectedly.',
+  diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.',
   startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
-  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',
-  resetConfiguration: 'Reset Desktop and retry',
-  disableThirdPartyPlugins: 'Disable all third-party plugins and retry',
+  exitApplication: 'Exit',
+  restartApplication: 'Restart',
+  recoveryOperationFailed: 'The recovery operation failed',
+  disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -30,9 +29,6 @@ export const en = {
   enable: 'Enable',
   disable: 'Disable',
   disabled: 'Disabled',
-  retry: 'Retry startup',
-  disableAll: 'Disable all plugins and retry',
-  recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.',
   changingActivation: 'Changing plugin activation…',
   npmPackage: 'npm package',
   install: 'Install',
@@ -56,15 +52,14 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 
 export const zh = {
   application: '应用',
-  startupFailed: 'DeepSeek Harness 无法启动',
-  startupLoading: '正在启动 DeepSeek Harness…',
-  startupLoadingDescription: '准备就绪后将自动打开工作区。',
-  startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。',
+  startupFailed: 'DeepSeek Harness 无法使用',
+  fatalSummary: '应用无法启动或已意外停止。',
+  diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。',
   startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
-  startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。',
-  restartApplication: '关闭并重启',
-  resetConfiguration: '重置 Desktop 并重试',
-  disableThirdPartyPlugins: '禁用全部第三方插件并重试',
+  exitApplication: '退出',
+  restartApplication: '重启',
+  recoveryOperationFailed: '恢复操作失败',
+  disableThirdPartyPlugins: '禁用全部第三方插件并重启',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',
@@ -84,9 +79,6 @@ export const zh = {
   enable: '启用',
   disable: '禁用',
   disabled: '已禁用',
-  retry: '重试启动',
-  disableAll: '禁用全部插件并重试',
-  recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。',
   changingActivation: '正在更改插件启用状态…',
   npmPackage: 'npm 包',
   install: '安装',

+ 57 - 136
apps/desktop/src/main.ts

@@ -20,32 +20,34 @@ import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
 import { desktopNodeEnvironment } from './node-environment.ts'
-import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
+import { DesktopBackendController } from './backend-controller.ts'
 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 } from './startup-error.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
-import { startupFailureDocument } from './startup-document.ts'
+import { DesktopFatalRecovery } from './fatal-recovery.ts'
 
 const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
-type RecoveryAction = 'restart' | 'plugins' | 'reset'
-let profileRecoveryAvailable = (): boolean => false
-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()
-}
+let stopForRecovery = async (): Promise<void> => {}
+let shuttingDown = false
+const recovery = new DesktopFatalRecovery({
+  messages: () => resolveDesktopLocale(app.getLocale()).messages,
+  show: options => dialog.showMessageBox(options),
+  stop: () => { shuttingDown = true; return stopForRecovery() },
+  disablePlugins: async () => {
+    const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
+    await manager.disableAllPlugins()
+  },
+  exit: () => { app.quit() },
+  restart: () => { app.relaunch(); app.quit() },
+})
 
-async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
-  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable())
-  const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
-  emergencyPages.set(window, { url, message, busy: false })
-  await window.loadURL(url)
+function reportFatal(error: unknown): void {
+  console.error(error)
+  if (shuttingDown) return
+  void recovery.report(error).catch((failure: unknown) => { console.error(failure); app.exit(1) })
 }
 
 protocol.registerSchemesAsPrivileged([{
@@ -153,15 +155,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
       event.preventDefault()
       if (['http:', 'https:'].includes(destination.protocol)) void shell.openExternal(url)
     }
-    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
-    if (action.hostname !== 'restart' && !profileRecoveryAvailable()) 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
 }
@@ -201,8 +194,6 @@ async function main(): Promise<void> {
   const development = !app.isPackaged
   const activeProject = paths.profile
   const manager = new DesktopProjectManager(paths, resources)
-  profileRecoveryAvailable = () => manager.canRecoverProfile()
-  let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
   let quitting = false
   let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
@@ -213,44 +204,25 @@ async function main(): Promise<void> {
   const messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
-  const startupUrl = `${SCHEME}://shell/startup.html`
   const applicationUrl = `${SCHEME}://app/`
   let hostUrl: string | undefined
   let hostCookie: string | undefined
   let injections: readonly unknown[] = []
   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 }
-    if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
-  }
-
   const navigateMain = (url: string): Promise<void> => {
     const window = mainWindow
-    if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
+    if (quitting || 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) => {
-      if (quitting || window.isDestroyed() || navigation !== next) return
+      if (quitting || shuttingDown || window.isDestroyed() || navigation !== next
+        || (error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED')) return
       navigation = undefined
       throw error
     })
     navigation = next
     return next.promise
   }
-  const backendState = (): DesktopBackendState => {
-    const state = pageError ?? backend.state
-    return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state
-  }
-  const publishBackend = (state: DesktopBackendState): void => {
-    for (const window of BrowserWindow.getAllWindows()) {
-      window.webContents.send(DESKTOP_IPC.backendState, state)
-    }
-  }
   const backend = new DesktopBackendController((onFailure) => {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
@@ -269,9 +241,7 @@ async function main(): Promise<void> {
       stop: () => host.stop(),
     }
   }, (state) => {
-    if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
-    publishBackend(backendState())
-    if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
+    if (state.phase === 'error') reportFatal(new Error(state.message))
   })
 
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
@@ -287,42 +257,17 @@ async function main(): Promise<void> {
     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 (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice)
-    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)
-  }
+  stopForRecovery = () => backend.close()
 
-  const showStartupError = async (error: unknown): Promise<void> => {
-    if (quitting) return
-    pageError = desktopErrorState(error)
-    try { await navigateMain(startupUrl) }
-    catch (navigationError) {
-      await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
-    }
-    publishBackend(backendState())
-  }
   const reconcileBackend = (): Promise<void> => {
     startup ??= (async () => {
-      pageError = undefined
       await navigateMain(applicationUrl)
       await backend.start(async () => {
-        await manager.applyRelease()
+        await manager.applyRelease(app.isPackaged)
       })
       // The existing Web document resumes through the boot IPC response.
-    })().catch(async (error: unknown) => {
-      await showStartupError(error)
+    })().catch((error: unknown) => {
+      reportFatal(error)
       throw error
     }).finally(() => { startup = undefined })
     return startup
@@ -348,13 +293,7 @@ async function main(): Promise<void> {
       }
       return forwardWebRequest(request, hostUrl, hostCookie)
     }
-    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 === 'shell') return serveShellAsset(request)
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
@@ -365,6 +304,15 @@ async function main(): Promise<void> {
     return { injections, streamBaseUrl: new URL(hostUrl).origin }
   })
 
+  ipcMain.handle(DESKTOP_IPC.bootFailed, (event, message: unknown) => {
+    assertDesktopSender(event, ['app'])
+    if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) {
+      throw new Error('dsh desktop: rejected startup failure from a non-primary frame')
+    }
+    if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text')
+    reportFatal(new Error(message))
+  })
+
   session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
     if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
       callback({})
@@ -381,16 +329,17 @@ async function main(): Promise<void> {
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     await startup?.catch(() => undefined)
-    pageError = undefined
-    await navigateMain(startupUrl)
+    if (recovery.active) throw new Error(messages.fatalSummary)
     try {
       await manager.mutate(mutation, hooks)
-      await navigateMain(applicationUrl)
-    } catch (error) {
-      await showStartupError(error)
-      throw error
+    } finally {
+      if (backend.state.phase === 'ready') {
+        navigation = undefined
+        await navigateMain(applicationUrl).catch(reportFatal)
+      }
     }
   }
+
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
     return locale
@@ -422,37 +371,6 @@ async function main(): Promise<void> {
     if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
     return mutate(event, { type: 'plugin-toggle', name, enabled })
   })
-  ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
-  ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
-    assertDesktopSender(event, ['shell'])
-    return backendState()
-  })
-  ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    await reconcileBackend()
-    focusPrimaryWindow()
-  })
-  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'])
-    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()
@@ -535,23 +453,27 @@ async function main(): Promise<void> {
     const window = createWindow(appPreload, true)
     mainWindow = window
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
+      if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
+        reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`))
+      }
+    })
     window.webContents.on('preload-error', (_event, _path, error) => {
-      void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed()) reportFatal(error)
     })
     window.webContents.on('render-process-gone', (_event, details) => {
       navigation = undefined
-      emergencyDocument = false
-      void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
-        .catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') {
+        reportFatal(new Error(`Desktop renderer exited: ${details.reason}`))
+      }
     })
     return window
   }
   focusPrimaryWindow = () => {
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
-      createMainWindow()
-      void navigateMain(applicationUrl)
-        .catch((error: unknown) => { console.error(error) })
+      try { createMainWindow() } catch (error) { reportFatal(error); return }
+      void navigateMain(applicationUrl).catch(reportFatal)
       return
     }
     if (window.isMinimized()) window.restore()
@@ -566,6 +488,7 @@ async function main(): Promise<void> {
     if (process.platform !== 'darwin') app.quit()
   })
   app.on('before-quit', (event) => {
+    shuttingDown = true
     if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
     quitting = true
@@ -594,9 +517,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)
   }
-  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)
+  reportFatal(error)
 }).catch((error: unknown) => {
   console.error(error)
   app.exit(1)

+ 0 - 26
apps/desktop/src/owned-directory.ts

@@ -1,26 +0,0 @@
-/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */
-
-import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
-import { join } from 'node:path'
-
-/**
- * Remove an owned directory and its contents, unlinking root and nested links.
- * Missing roots are ignored; existing roots must be directories or links.
- * @param path - Owned directory or link to remove; link targets are preserved.
- */
-export function removeOwnedDirectory(path: string): void {
-  const stat = lstatSync(path, { throwIfNoEntry: false })
-  if (stat === undefined) return
-  if (stat.isSymbolicLink()) {
-    unlinkSync(path)
-    return
-  }
-  if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
-  // Electron's recursive rm follows nested Windows junctions into installed resources.
-  for (const entry of readdirSync(path, { withFileTypes: true })) {
-    const child = join(path, entry.name)
-    if (entry.isDirectory()) removeOwnedDirectory(child)
-    else unlinkSync(child)
-  }
-  rmdirSync(path)
-}

+ 7 - 22
apps/desktop/src/preload-app.ts

@@ -1,32 +1,17 @@
-/** Startup controls for shell documents; application documents receive only the carrier marker. */
+/** Context-isolated application boot bridge and desktop carrier marker. */
 
 import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts'
+import { DESKTOP_IPC } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
-
-const startup: DshDesktopStartupApi = {
-  protocolVersion: 1,
-  locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
-  backend: {
-    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['backend']['status']>,
-    subscribe(listener) {
-      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
-      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
-      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
-    },
-  },
-  disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
-  restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
-  resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
-}
 
 if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
-  contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown> })
+  contextBridge.exposeInMainWorld('dshDesktopBoot', {
+    ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
+    failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,
+  })
 }
 
 markDocumentPlatform()
 syncNativeTheme()
-contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
-  ? startup : { protocolVersion: 1 })
+contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })

+ 0 - 11
apps/desktop/src/preload.ts

@@ -3,7 +3,6 @@
 import { contextBridge, ipcRenderer } from 'electron'
 import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
 
 const api: DshDesktopApi = {
   protocolVersion: 1,
@@ -13,18 +12,8 @@ const api: DshDesktopApi = {
     add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise<void>,
     remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise<void>,
     toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise<void>,
-    disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
     update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
   },
-  backend: {
-    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopApi['backend']['status']>,
-    retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
-    subscribe(listener) {
-      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
-      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
-      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
-    },
-  },
   updates: {
     check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
     install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,

+ 91 - 0
apps/desktop/src/profile-core-cleanup.ts

@@ -0,0 +1,91 @@
+/** Remove application-owned packages left in external plugin profiles before production boot. */
+
+import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { dump, load } from 'js-yaml'
+import { DESKTOP_PACKAGE_SET_FILE, readDesktopCorePackageSet } from './core-package-set.ts'
+
+/** Enables the temporary production-profile cleanup; remove this module and its caller together. */
+const CLEAN_PROFILE_CORE_PACKAGES = true
+
+function object(value: unknown): Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    throw new Error('desktop profile cleanup: expected an object')
+  }
+  return value as Record<string, unknown>
+}
+
+function stat(path: string): ReturnType<typeof lstatSync> | undefined {
+  try { return lstatSync(path) } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return undefined
+  }
+}
+
+function remove(path: string): boolean {
+  const entry = stat(path)
+  if (entry === undefined) return false
+  if (entry.isSymbolicLink()) unlinkSync(path)
+  else rmSync(path, { recursive: entry.isDirectory() })
+  return true
+}
+
+function requireDirectory(path: string): void {
+  const entry = stat(path)
+  if (entry !== undefined && (!entry.isDirectory() || entry.isSymbolicLink())) {
+    throw new Error(`desktop profile cleanup: package parent is not a real directory: ${path}`)
+  }
+}
+
+function prune(value: Record<string, unknown>, field: string, names: ReadonlySet<string>): boolean {
+  if (value[field] === undefined) return false
+  const entries = object(value[field])
+  let changed = false
+  for (const name of names) {
+    if (!Object.hasOwn(entries, name)) continue
+    Reflect.deleteProperty(entries, name)
+    changed = true
+  }
+  return changed
+}
+
+/**
+ * Delete current and recorded Desktop core packages, their declarations, and stale lockfile resolutions.
+ * Managed links are unlinked without touching their targets; unrelated plugins and profile configuration survive.
+ * The caller holds the profile lock and must not have started the Host. Development launches do nothing.
+ * @param profile - Absolute Desktop profile directory.
+ * @param packageNames - Package names from the verified application runtime descriptor.
+ * @param production - Whether this launch uses the packaged application.
+ */
+export function cleanProfileCorePackages(profile: string, packageNames: readonly string[], production: boolean): void {
+  // oxlint-disable-next-line typescript/no-unnecessary-condition -- release cleanup has an explicit source switch.
+  if (!CLEAN_PROFILE_CORE_PACKAGES || !production || !existsSync(profile)) return
+  const names = new Set(packageNames)
+  const recordPath = join(profile, DESKTOP_PACKAGE_SET_FILE)
+  if (existsSync(recordPath)) {
+    for (const entry of readDesktopCorePackageSet(profile).packages) names.add(entry.name)
+  }
+  const roots = [join(profile, 'node_modules'), join(profile, '.dsh-module-fallback', 'node_modules')]
+  requireDirectory(join(profile, '.dsh-module-fallback'))
+  for (const root of roots) {
+    requireDirectory(root)
+    for (const name of names) requireDirectory(dirname(join(root, name)))
+  }
+  const manifestPath = join(profile, 'package.json')
+  const manifest = existsSync(manifestPath) ? object(JSON.parse(readFileSync(manifestPath, 'utf8'))) : undefined
+  const workspacePath = join(profile, 'pnpm-workspace.yaml')
+  const workspace = existsSync(workspacePath) ? object(load(readFileSync(workspacePath, 'utf8'))) : undefined
+  let manifestChanged = false
+  if (manifest !== undefined) {
+    for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
+      manifestChanged = prune(manifest, field, names) || manifestChanged
+    }
+    if (manifest.pnpm !== undefined) manifestChanged = prune(object(manifest.pnpm), 'overrides', names) || manifestChanged
+  }
+  const workspaceChanged = workspace !== undefined && prune(workspace, 'overrides', names)
+  const packageResidue = roots.some(root => [...names].some(name => stat(join(root, name)) !== undefined))
+  if (manifestChanged || workspaceChanged || packageResidue) remove(join(profile, 'pnpm-lock.yaml'))
+  if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`)
+  if (workspaceChanged) writeFileSync(workspacePath, dump(workspace))
+  for (const root of roots) for (const name of names) remove(join(root, name))
+}

+ 22 - 31
apps/desktop/src/project-manager.ts

@@ -10,7 +10,6 @@ import {
   openSync,
   closeSync,
   readFileSync,
-  readdirSync,
   unlinkSync,
   writeFileSync,
   writeSync,
@@ -23,7 +22,6 @@ import {
   verifyDesktopCorePackageSet,
 } from './core-package-set.ts'
 import type { DesktopPaths } from './paths.ts'
-import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
 import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
@@ -31,6 +29,7 @@ import {
   unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
 } from '@deepseek-ai/dsh-app-boot'
 import { migrateDesktopProfileLinks } from './profile-packages.ts'
+import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
 
 /** Desktop plugin record derived from the installed profile. */
 export interface DesktopPluginRecord {
@@ -48,11 +47,11 @@ export interface DesktopRuntimeExecutables {
   readonly dsh: string
 }
 
-/** Hooks that stop the backend before profile writes and restart it after success. */
+/** Hooks that stop the backend before profile writes and restart it after each attempted change. */
 export interface DesktopProjectHooks {
   /** Stop the active backend and await process exit before modifying its files. */
   beforeChange(): Promise<void>
-  /** Start the modified profile after package preparation succeeds. */
+  /** Start the current profile even when package preparation failed with partial writes. */
   afterChange(): Promise<void>
 }
 
@@ -62,7 +61,6 @@ export type DesktopProjectMutation =
   | { readonly type: 'plugin-remove'; readonly name: string }
   | { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
   | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
-  | { readonly type: 'plugins-disable-all' }
 
 const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
 const DSH_PACKAGE = '@deepseek-ai/dsh'
@@ -124,22 +122,13 @@ export class DesktopProjectManager {
   }
 
   /**
-   * 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.
+   * Disable third-party bundles without loading application resources or deleting plugin files.
+   * @returns Completion of the locked profile write; the caller must stop the Host first.
    */
-  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)
-      await hooks.afterChange()
+  async disableAllPlugins(): Promise<void> {
+    await this.withLock(() => {
+      if (!existsSync(join(this.paths.profile, 'package.json'))) return
+      writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
     })
   }
 
@@ -148,11 +137,6 @@ export class DesktopProjectManager {
     return this.currentRuntime().release.version
   }
 
-  /** @returns Whether application resources support profile recovery. */
-  canRecoverProfile(): boolean {
-    return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
-  }
-
   private currentRuntime(): DesktopRuntimeDescriptor {
     if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
     return this.descriptor
@@ -167,10 +151,14 @@ export class DesktopProjectManager {
     return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') }
   }
 
-  /** Load application metadata and initialize missing profile files without installing packages. */
-  async applyRelease(): Promise<void> {
+  /**
+   * Load application metadata and prepare the external plugin profile without installing packages.
+   * @param production - Remove application-owned profile packages before packaged Host startup.
+   */
+  async applyRelease(production = false): Promise<void> {
     await this.withLock(() => {
       this.descriptor = this.readRuntime()
+      cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production)
       migrateProfileSettings(this.paths.profile)
       migrateDesktopProfileLinks(this.paths.profile)
       createPluginProfile(this.paths.profile)
@@ -183,16 +171,19 @@ export class DesktopProjectManager {
       this.currentRuntime()
       if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
       await hooks.beforeChange()
-      if (mutation.type === 'plugins-disable-all') {
-        writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
-      } else {
+      try {
         await this.applyMutation(this.paths.profile, mutation)
+      } catch (error) {
+        try { await hooks.afterChange() } catch (restartError) {
+          throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
+        }
+        throw error
       }
       await hooks.afterChange()
     })
   }
 
-  private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
+  private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise<void> {
     const location = this.pluginLocation(projectDir)
     const before = readProfilePlugins(location)
     const bundles = before.manifest.dsh?.profile?.bundles ?? []

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

@@ -1,26 +0,0 @@
-/** 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.
- * @param profileRecovery - Whether the initialized application can repair its profile.
- * @returns An HTML document suitable for an isolated emergency window.
- */
-export function startupFailureDocument(locale: DesktopLocale, message: string, profileRecovery = false): 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>
-${profileRecovery ? `<p>${escape(locale.messages.startupConfigurationAdvice)}</p>` : ''}
-<pre role="alert">${escape(message)}</pre>
-<form action="dsh-recovery://restart"><button>${escape(locale.messages.restartApplication)}</button></form>
-${profileRecovery ? `<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>`
-}

+ 1 - 1
apps/desktop/tests/development-project.spec.ts

@@ -79,7 +79,7 @@ describe('desktop development project', () => {
     expect(manager.listPlugins()).toEqual([{ name: 'development-plugin', version: '1.0.0', enabled: true }])
     await manager.mutate({ type: 'plugin-remove', name: 'development-plugin' }, hooks)
     expect(manager.listPlugins()).toEqual([])
-    await manager.resetConfiguration(hooks)
+    await manager.disableAllPlugins()
     expect(readFileSync(join(cli, 'package.json'), 'utf8')).toBe('{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n')
     expect(readFileSync(join(host, 'lib', 'index.js'), 'utf8')).toBe('')
 

+ 9 - 0
apps/desktop/tests/expected/fatal-dialog-en.txt

@@ -0,0 +1,9 @@
+DeepSeek Harness is unavailable
+The application could not start or stopped unexpectedly.
+Desktop Host failed
+Plugin initialization failed
+
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Exit
+Restart
+Disable all third-party plugins and restart

+ 9 - 0
apps/desktop/tests/expected/fatal-dialog-zh-CN.txt

@@ -0,0 +1,9 @@
+DeepSeek Harness 无法使用
+应用无法启动或已意外停止。
+Desktop Host failed
+Plugin initialization failed
+
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+退出
+重启
+禁用全部第三方插件并重启

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

@@ -1,8 +0,0 @@
-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

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

@@ -1,5 +0,0 @@
-DeepSeek Harness could not start
-Choose a recovery action below. Disabling third-party plugins retains their files.
-If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
-Failure details
-Close and restart

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

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

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

@@ -1,5 +0,0 @@
-DeepSeek Harness 无法启动
-请选择下方的恢复操作。禁用第三方插件会保留插件文件。
-如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
-Failure details
-关闭并重启

+ 104 - 0
apps/desktop/tests/fatal-recovery.spec.ts

@@ -0,0 +1,104 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import type { MessageBoxOptions } from 'electron'
+import { DesktopFatalRecovery } from '../src/fatal-recovery.ts'
+import { resolveDesktopLocale } from '../src/locale.ts'
+
+function fixture(locale = 'en') {
+  const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>()
+  const stopped = Promise.withResolvers<undefined>()
+  const operations = {
+    messages: () => resolveDesktopLocale(locale).messages,
+    show: vi.fn((_options: MessageBoxOptions) => choice.promise),
+    stop: vi.fn(() => stopped.promise),
+    disablePlugins: vi.fn(async () => {}),
+    exit: vi.fn(),
+    restart: vi.fn(),
+  }
+  return { operations, choice, stopped, recovery: new DesktopFatalRecovery(operations) }
+}
+
+afterEach(() => { vi.restoreAllMocks() })
+
+it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) => {
+  const { operations, choice, stopped, recovery } = fixture(locale)
+  const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed'))
+  const options = operations.show.mock.calls[0]![0]
+  await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n')
+    .toMatchFileSnapshot(`expected/fatal-dialog-${locale}.txt`)
+  choice.resolve({ response: 0, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+})
+
+it('locks the first report before the dialog settles and never resets after an action', async () => {
+  const { operations, choice, stopped, recovery } = fixture()
+  const pending = recovery.report(new Error('first failure'))
+  await recovery.report(new Error('second failure'))
+  expect(operations.show).toHaveBeenCalledOnce()
+  expect(operations.show.mock.calls[0]![0].detail).toContain('first failure')
+  choice.resolve({ response: 1, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+  await recovery.report(new Error('third failure'))
+  expect(operations.show).toHaveBeenCalledOnce()
+})
+
+it.each([0, 1, 2])('waits for shutdown before executing choice %s', async (response) => {
+  const { operations, choice, stopped, recovery } = fixture()
+  const stopping = Promise.withResolvers<undefined>()
+  operations.stop.mockImplementation(() => { stopping.resolve(undefined); return stopped.promise })
+  const pending = recovery.report(new Error('fatal'))
+  choice.resolve({ response, checkboxChecked: false })
+  await stopping.promise
+  expect(operations.exit).not.toHaveBeenCalled()
+  expect(operations.restart).not.toHaveBeenCalled()
+  expect(operations.disablePlugins).not.toHaveBeenCalled()
+  stopped.resolve(undefined)
+  await pending
+  expect(operations.exit).toHaveBeenCalledTimes(response === 0 ? 1 : 0)
+  expect(operations.restart).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
+  expect(operations.disablePlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+})
+
+it('reports a user-requested disable failure and allows exit without restarting', async () => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, stopped, recovery } = fixture()
+  operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false })
+    .mockResolvedValueOnce({ response: 0, checkboxChecked: false })
+  operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only'))
+  stopped.resolve(undefined)
+  await recovery.report(new Error('fatal'))
+  expect(operations.show).toHaveBeenCalledTimes(2)
+  expect(operations.show.mock.calls[1]![0].detail).toContain('profile is read-only')
+  expect(operations.restart).not.toHaveBeenCalled()
+  expect(operations.exit).toHaveBeenCalledOnce()
+})
+
+it('allows exit after shutdown cleanup fails', async () => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, choice, stopped, recovery } = fixture()
+  const pending = recovery.report(new Error('fatal'))
+  choice.resolve({ response: 0, checkboxChecked: false })
+  stopped.reject(new Error('cleanup failed'))
+  await pending
+  expect(operations.exit).toHaveBeenCalledOnce()
+})
+
+it.each(['en', 'zh-CN'])('bounds long diagnostics and recovery-operation errors in %s', async (locale) => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, stopped, recovery } = fixture(locale)
+  operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false })
+    .mockResolvedValueOnce({ response: 0, checkboxChecked: false })
+  operations.disablePlugins.mockRejectedValueOnce(new Error('read-only\n'.repeat(5000) + 'final write failure'))
+  stopped.resolve(undefined)
+  await recovery.report(new Error('😀'.repeat(32768) + '\nfinal backend failure'))
+  for (const [options] of operations.show.mock.calls) {
+    expect(options.detail!.length).toBeLessThanOrEqual(1200)
+    expect(options.detail!.split('\n').length).toBeLessThanOrEqual(12)
+    expect(options.detail).toContain(operations.messages().diagnosticTruncated)
+    expect(options.detail).toContain(operations.messages().startupReinstallAdvice)
+    expect(options.detail!.isWellFormed()).toBe(true)
+  }
+  expect(operations.show.mock.calls[0]![0].detail).toContain('final backend failure')
+  expect(operations.show.mock.calls[1]![0].detail).toContain('final write failure')
+})

+ 0 - 29
apps/desktop/tests/fixtures/owned-directory-smoke.mjs

@@ -1,29 +0,0 @@
-/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */
-
-import assert from 'node:assert/strict'
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { removeOwnedDirectory } from '../../lib/types/owned-directory.js'
-import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js'
-
-assert.ok(process.versions.electron, 'This regression must run under Electron')
-const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-'))
-try {
-  const resources = join(root, 'resources')
-  mkdirSync(resources)
-  writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes')
-  const inventory = inventoryDesktopRuntime(resources)
-  const profile = join(root, 'rollback-profile')
-  const packages = join(profile, 'node_modules', '@unknown')
-  mkdirSync(packages, { recursive: true })
-  symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir')
-  writeFileSync(join(packages, 'private-file'), 'removed')
-  removeOwnedDirectory(profile)
-  assert.equal(existsSync(profile), false)
-  assert.deepEqual(inventoryDesktopRuntime(resources), inventory)
-  assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes')
-  console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true }))
-} finally {
-  removeOwnedDirectory(root)
-}

+ 140 - 152
apps/desktop/tests/main-startup.spec.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { join } from 'node:path'
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
@@ -13,14 +14,15 @@ const harness = await vi.hoisted(async () => {
     return { promise, resolve, reject }
   }
   const windows: FakeWindow[] = []
+  let windowFailure: Error | undefined
   const hosts: FakeHost[] = []
-  const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
+  const handlers = new Map<string, (event: { senderFrame: { url: string } }, ...args: unknown[]) => unknown>()
   let pluginsEnabled = false
   let preparing = deferred()
   let prepared = deferred()
   let hostStarted = deferred()
   let navigated = deferred()
-  let errorPublished = deferred()
+  let dialogShown = deferred()
   let quitCompleted = deferred()
   class FakeWindow extends EventEmitter {
     destroyed = false
@@ -30,14 +32,12 @@ const harness = await vi.hoisted(async () => {
       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()
-      }),
+      send: vi.fn(),
     })
     readonly show = vi.fn()
     readonly focus = vi.fn()
     readonly restore = vi.fn()
-    constructor(readonly options: { show: boolean }) { super(); windows.push(this) }
+    constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) }
     isDestroyed() { return this.destroyed }
     isMinimized() { return false }
     async loadURL(url: string) {
@@ -83,16 +83,18 @@ const harness = await vi.hoisted(async () => {
   const popup = vi.fn()
   return {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
+    failWindow(error: Error) { windowFailure = error },
     popup,
     socketHeaders: vi.fn(),
     menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
-    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
+    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
-    canRecoverProfile: vi.fn(() => true),
+    mutateFailure: vi.fn<() => void>(),
+    disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
-    get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
+    get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
     nextNavigation() { navigated = deferred(); return navigated.promise },
     nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
     get pluginsEnabled() { return pluginsEnabled },
@@ -100,9 +102,10 @@ const harness = await vi.hoisted(async () => {
     reset() {
       windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
       app.isPackaged = true
+      windowFailure = undefined
       pluginsEnabled = false
       preparing = deferred(); prepared = deferred(); hostStarted = deferred()
-      navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
+      navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
     },
   }
 })
@@ -125,24 +128,30 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk
 vi.mock('../src/project-manager.ts', () => ({
   DesktopProjectManager: class {
     readonly applyRelease = harness.applyRelease
-    canRecoverProfile = harness.canRecoverProfile
+    disableAllPlugins = harness.disableAllPlugins
     async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
       await hooks.beforeChange()
-      harness.pluginsEnabled = false
+      try {
+        harness.mutateFailure()
+        harness.pluginsEnabled = false
+      } catch (error) {
+        try { await hooks.afterChange() } catch (restartError) {
+          throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
+        }
+        throw error
+      }
       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 }))
 vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() }))
 
-function invoke(channel: string): unknown {
+function invoke(channel: string, ...args: unknown[]): unknown {
   const handler = harness.handlers.get(channel)
   if (handler === undefined) throw new Error(`missing handler ${channel}`)
-  return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } })
+  return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } }, ...args)
 }
 
 beforeEach(() => {
@@ -150,6 +159,8 @@ beforeEach(() => {
   vi.clearAllMocks()
   vi.useFakeTimers()
   harness.reset()
+  harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
+  vi.spyOn(console, 'error').mockImplementation(() => {})
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
   vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -162,7 +173,6 @@ afterEach(async () => {
   harness.app.quit()
   await harness.quitCompleted.promise
   vi.restoreAllMocks()
-  harness.canRecoverProfile.mockReturnValue(true)
   vi.clearAllTimers()
   vi.useRealTimers()
   vi.unstubAllEnvs()
@@ -191,7 +201,7 @@ describe('desktop main startup', () => {
     harness.prepared.resolve()
     await harness.hostStarted.promise
     harness.hosts[0]!.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
     const handler = harness.socketHeaders.mock.calls[0]![1] as (
       details: { url: string; webContentsId: number; requestHeaders: Record<string, string> },
       callback: (result: unknown) => void,
@@ -253,36 +263,6 @@ describe('desktop main startup', () => {
     expect(harness.menu.buildFromTemplate).not.toHaveBeenCalled()
   })
 
-  it('keeps a local document while retry connects a replacement Host', async () => {
-    await import('../src/main.ts')
-    await harness.preparing.promise
-    harness.prepared.resolve()
-    await harness.hostStarted.promise
-    const first = harness.hosts[0]!
-    first.url = 'http://127.0.0.1:40001/?token=first'
-    first.ready.resolve()
-    await harness.navigated.promise
-    const window = harness.windows[0]!
-    expect(window.urls).toEqual(['dsh-app://app/'])
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    first.onFailure!(new Error('backend exited'))
-    await harness.errorPublished.promise
-    await first.stopping.promise
-    first.exited.resolve()
-    const nextStarted = harness.nextHostStart()
-    const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    await nextStarted
-    const replacement = harness.hosts[1]!
-    replacement.url = 'http://127.0.0.1:40002/?token=replacement'
-    replacement.ready.resolve()
-    await retry
-    expect(harness.windows).toHaveLength(1)
-    expect(window.urls).toEqual([
-      'dsh-app://app/', 'dsh-app://shell/startup.html', 'dsh-app://app/',
-    ])
-    expect(harness.openExternal).not.toHaveBeenCalled()
-  })
-
   it('opens message links externally while retaining same-origin application navigation', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -308,131 +288,148 @@ describe('desktop main startup', () => {
     expect(harness.openExternal).not.toHaveBeenCalled()
   })
 
-  it('exits with a diagnostic when both initialization and emergency navigation fail', async () => {
-    const exited = Promise.withResolvers<undefined>()
-    vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') })
-    vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed'))
-    vi.spyOn(console, 'error').mockImplementation(() => {})
-    harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) })
+  it('reports a window construction failure without requiring a window', async () => {
+    const shown = Promise.withResolvers<undefined>()
+    harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
+    harness.failWindow(new Error('window creation failed'))
     await import('../src/main.ts')
-    await exited.promise
-    expect(harness.app.exit).toHaveBeenCalledWith(1)
-    expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' }))
+    await shown.promise
+    expect(harness.windows).toHaveLength(0)
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('window creation failed')
   })
 
-  it('withholds profile recovery after application resources fail to load', async () => {
-    harness.canRecoverProfile.mockReturnValue(false)
+  it('accepts Web fatal reports only from the primary application frame', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
-    harness.prepared.reject(new Error('runtime resources missing'))
-    await harness.errorPublished.promise
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false })
     const window = harness.windows[0]!
-    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
-    const html = decodeURIComponent(window.urls.at(-1)!)
-    expect(html).toContain('dsh-recovery://restart')
-    expect(html).not.toContain('dsh-recovery://reset')
-    expect(html).not.toContain('dsh-recovery://plugins')
+    const frame = { url: 'dsh-app://app/' }
+    Object.assign(window.webContents, { mainFrame: frame })
+    const handler = harness.handlers.get(DESKTOP_IPC.bootFailed)! as unknown as (event: unknown, message: unknown) => void
+    const event = { sender: window.webContents, senderFrame: frame }
+    expect(() => { handler({ ...event, senderFrame: { url: 'https://other.example/' } }, 'untrusted') }).toThrow('unowned renderer')
+    expect(() => { handler({ ...event, senderFrame: { ...frame } }, 'subframe') }).toThrow('non-primary frame')
+    expect(() => { handler(event, {}) }).toThrow('must be text')
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    handler(event, 'client mount failed')
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('client mount failed')
+    expect(window.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('reloads a crashed startup renderer in the same window', async () => {
+  it('ignores subresource failures and navigation cancellation but reports a failed main document', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     const window = harness.windows[0]!
-    window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
-    await harness.errorPublished.promise
-    expect(window.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
+    window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/image.png', false)
+    window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true)
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true)
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load')
   })
 
-  it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
+  it('returns package-operation failures locally without opening fatal recovery', 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'))
     harness.prepared.resolve()
     await harness.hostStarted.promise
-    harness.hosts[0]!.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    const started = harness.nextHostStart()
-    const navigated = harness.nextNavigation()
-    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
+    const host = harness.hosts[0]!
+    host.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
+    harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
+    host.exited.resolve()
+    const nextStarted = harness.nextHostStart()
+    const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('package write failed')
+    await nextStarted
     harness.hosts[1]!.ready.resolve()
-    await navigated
-    expect(event.preventDefault).toHaveBeenCalled()
-    expect(window.urls.at(-1)).toBe('dsh-app://app/')
+    await failure
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/'])
   })
 
-  it('allows a full profile reset for an unclassified startup failure', async () => {
+  it('opens fatal recovery when the Host cannot restart after a package change', 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' })
+    const host = harness.hosts[0]!
+    host.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
+    host.exited.resolve()
+    const nextStarted = harness.nextHostStart()
+    const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('new Host failed')
+    await nextStarted
+    const replacement = harness.hosts[1]!
+    replacement.exited.resolve()
+    replacement.ready.reject(new Error('new Host failed'))
+    await failure
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('new Host failed')
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('keeps a self-contained reinstall document in the main window after preload failure', async () => {
+  it('offers all recovery choices when resources fail before the Host starts', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.reject(new Error('runtime resources missing'))
+    await harness.dialogShown.promise
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
+  })
+
+  it.each(['preload', 'renderer'])('retains the document after a fatal %s failure and reports only the first error', async (kind) => {
     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()
+    if (kind === 'preload') window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
+    else window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
+    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('secondary failure'))
+    harness.prepared.reject(new Error('backend also failed'))
+    await harness.dialogShown.promise
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).not.toContain('secondary failure')
+    expect(window.urls).toEqual(['dsh-app://app/'])
   })
 
-  it.each([true, false])('offers plugin recovery and disables plugins in packaged=%s mode', async (packaged) => {
-    harness.app.isPackaged = packaged
-    harness.pluginsEnabled = true
+  it('ignores clean renderer exits and exits of a closed window', 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('Plugin initialization failed'))
-    await harness.errorPublished.promise
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true })
-    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' })
+    const window = harness.windows[0]!
+    window.webContents.emit('render-process-gone', {}, { reason: 'clean-exit' })
+    window.close()
+    window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
   })
 
-  it('waits for Host exit before relaunching the application', async () => {
+  it('reports a rejected document load without navigating to a recovery page', async () => {
+    const shown = Promise.withResolvers<undefined>()
+    harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
+    vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('document missing'))
+    await import('../src/main.ts')
+    await shown.promise
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('document missing')
+    expect(harness.hosts).toHaveLength(0)
+  })
+
+  it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => {
+    harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false })
     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
+    const host = harness.hosts[0]!
+    host.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
+    host.onFailure!(new Error('backend exited'))
+    await host.stopping.promise
     expect(harness.app.relaunch).not.toHaveBeenCalled()
-    harness.hosts[0]!.exited.resolve()
-    await restart
-    expect(harness.app.relaunch).toHaveBeenCalledOnce()
+    expect(harness.disableAllPlugins).not.toHaveBeenCalled()
+    host.exited.resolve()
+    await harness.quitCompleted.promise
+    expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
+    expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
   it('shows the loading window before profile preparation and starts one actual Host', async () => {
@@ -443,8 +440,8 @@ describe('desktop main startup', () => {
     expect(window.options.show).toBe(true)
     expect(window.urls).toEqual(['dsh-app://app/'])
     expect(harness.hosts).toHaveLength(0)
-    const retry = invoke(DESKTOP_IPC.backendRetry)
-    const secondRetry = invoke(DESKTOP_IPC.backendRetry)
+    const retry = invoke(DESKTOP_IPC.boot)
+    const secondRetry = invoke(DESKTOP_IPC.boot)
     harness.prepared.resolve()
     await harness.hostStarted.promise
     expect(harness.hosts).toHaveLength(1)
@@ -462,7 +459,6 @@ describe('desktop main startup', () => {
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual(['dsh-app://app/'])
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
   })
 
   it('prepares an independent plugin profile for the unpackaged Host', async () => {
@@ -480,28 +476,20 @@ describe('desktop main startup', () => {
     expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
   })
 
-  it('keeps startup errors and a successful retry in the same window', async () => {
+  it('keeps startup errors in the existing window without a shell retry handler', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     harness.prepared.resolve()
     await harness.hostStarted.promise
     const first = harness.hosts[0]!
-    const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed')
     first.exited.resolve()
     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', profileRecovery: true })
-    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
-    const nextStarted = harness.nextHostStart()
-    const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    await nextStarted
-    expect(harness.hosts).toHaveLength(2)
-    harness.hosts[1]!.ready.resolve()
-    await retry
-    expect(harness.windows).toHaveLength(1)
-    expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/')
-    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+    await harness.dialogShown.promise
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false)
+    await expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.')
+    expect(harness.hosts).toHaveLength(1)
   })
 
   it('waits for a pending child to exit on quit without late window navigation', async () => {

+ 0 - 53
apps/desktop/tests/owned-directory.spec.ts

@@ -1,53 +0,0 @@
-import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, expect, it } from 'vitest'
-import { removeOwnedDirectory } from '../src/owned-directory.ts'
-
-const roots: string[] = []
-function fixture(): string {
-  const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-'))
-  roots.push(root)
-  return root
-}
-
-afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
-
-it('removes nested files and directories while preserving unknown directory-link targets', () => {
-  const root = fixture()
-  const target = join(root, 'target')
-  mkdirSync(target)
-  writeFileSync(join(target, 'sentinel'), 'preserved')
-  const owned = join(root, 'owned')
-  const nested = join(owned, 'node_modules', '@unknown', 'private')
-  mkdirSync(nested, { recursive: true })
-  writeFileSync(join(nested, 'file'), 'remove')
-  symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir')
-  symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir')
-  removeOwnedDirectory(owned)
-  expect(existsSync(owned)).toBe(false)
-  expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved')
-})
-
-it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => {
-  const root = fixture()
-  const target = join(root, 'target')
-  if (!missing) {
-    mkdirSync(target)
-    writeFileSync(join(target, 'sentinel'), 'preserved')
-  }
-  const link = join(root, 'link')
-  symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
-  removeOwnedDirectory(link)
-  expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined()
-  if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved')
-})
-
-it('ignores a missing root and refuses to delete a regular-file root', () => {
-  const root = fixture()
-  expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow()
-  const file = join(root, 'file')
-  writeFileSync(file, 'preserved')
-  expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u)
-  expect(readFileSync(file, 'utf8')).toBe('preserved')
-})

+ 4 - 12
apps/desktop/tests/plugin-manager.spec.ts

@@ -4,27 +4,20 @@ import { JSDOM } from 'jsdom'
 import { expect, it, vi } from 'vitest'
 import { resolveDesktopLocale } from '../src/locale.ts'
 
-it('keeps disabled packages visible and offers recovery without a running backend', async () => {
+it('keeps disabled packages visible without an in-page recovery panel', async () => {
   const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
-  let enabled = true
-  let ready = false
-  const disableAll = vi.fn(async () => { enabled = false; ready = true })
+  let enabled = false
   const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
   const api = {
     locale: async () => resolveDesktopLocale('en'),
-    backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() },
-    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle },
+    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], toggle },
   }
   Object.defineProperty(dom.window, 'dshDesktop', { value: api })
   try {
     runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
     const document = dom.window.document
     await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
-    expect(document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(false)
-    expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0')
-    document.querySelector<HTMLButtonElement>('#disable-all')?.click()
-    await expect.poll(() => document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(true)
-    expect(disableAll).toHaveBeenCalledOnce()
+    expect(document.querySelector('#recovery')).toBeNull()
     expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"')
     document.querySelector<HTMLButtonElement>('#plugins li button')?.click()
     await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]])
@@ -38,7 +31,6 @@ it.each(['en', 'zh-CN'])('updates through the inline form, including the install
   const update = vi.fn(async () => {})
   Object.defineProperty(dom.window, 'dshDesktop', { value: {
     locale: async () => locale,
-    backend: { status: async () => ({ phase: 'ready' }) },
     plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update },
   } })
   try {

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

@@ -108,7 +108,7 @@ it.each(['directory', 'file', 'tarball', 'plain'] as const)('installs a %s sourc
     await manager.mutate({ type: 'plugin-add', spec }, hooks)
     expect(manager.listPlugins()).toEqual([{ name: 'local-plugin', version: '1.0.0', enabled: source !== 'plain' }])
     expect(existsSync(join(manager.paths.profile, 'node_modules/local-plugin/missing.yml'))).toBe(false)
-    await manager.mutate({ type: 'plugins-disable-all' }, hooks)
+    await manager.disableAllPlugins()
     expect(manager.listPlugins()[0]?.enabled).toBe(false)
     await manager.mutate({ type: 'plugin-remove', name: 'local-plugin' }, hooks)
     expect(manager.listPlugins()).toEqual([])

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

@@ -1,5 +1,5 @@
 import { afterEach, expect, it, vi } from 'vitest'
-import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts'
+import { DESKTOP_IPC } from '../src/ipc.ts'
 
 const electron = vi.hoisted(() => ({
   contextBridge: { exposeInMainWorld: vi.fn() },
@@ -11,41 +11,19 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 
-it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => {
+it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => {
   vi.stubGlobal('location', new URL(url))
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
 })
 
-it('provides startup controls and a removable state subscription to shell documents', async () => {
-  vi.stubGlobal('location', new URL('dsh-app://shell/startup.html'))
-  await import('../src/preload-app.ts')
-  const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi
-  await api.locale()
-  await api.backend.status()
-  await api.disablePlugins()
-  await api.resetConfiguration()
-  await api.restart()
-  expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
-    [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus],
-    [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart],
-  ])
-  const listener = vi.fn()
-  const dispose = api.backend.subscribe(listener)
-  const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void
-  handler({}, { phase: 'error', message: 'startup failed' })
-  expect(listener).toHaveBeenCalledWith({ phase: 'error', message: 'startup failed' })
-  dispose()
-  expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler)
-  expect(api).not.toHaveProperty('plugins')
-})
-
-
 it('exposes asynchronous boot only to the local application document', async () => {
   vi.stubGlobal('location', new URL('dsh-app://app/'))
   await import('../src/preload-app.ts')
-  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktopBoot')?.[1] as { ready(): Promise<unknown> }
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktopBoot')?.[1] as { ready(): Promise<unknown>; failed(message: string): Promise<void> }
   await api.ready()
+  await api.failed('client mount failed')
+  expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.bootFailed, 'client mount failed')
   expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.boot)
   vi.resetModules()
   electron.contextBridge.exposeInMainWorld.mockClear()

+ 22 - 0
apps/desktop/tests/primary-runtime-preparation.spec.ts

@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { zipSync } from 'fflate'
 import { expect, it } from 'vitest'
 import { downloadPrimaryRuntimeAsset, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
 
@@ -24,6 +25,27 @@ it('extracts a hash-verified cached library without a Python installer or networ
   }
 })
 
+it('fully extracts a large deflate-compressed wheel entry', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-'))
+  try {
+    const expected = Buffer.alloc(1024 * 1024)
+    // Seeded xorshift bytes keep the compressed entry larger than the stream buffers.
+    let state = 26
+    for (let index = 0; index < expected.length; index++) {
+      state ^= state << 13
+      state ^= state >>> 17
+      state ^= state << 5
+      expected[index] = state & 255
+    }
+    const archive = join(root, 'large.whl')
+    await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 }))
+    await unpackPrimaryRuntimeWheel(archive, root)
+    expect(await readFile(join(root, 'large.bin'))).toEqual(expected)
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
 it('rejects wheels that need installation outside site-packages', async () => {
   const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
   try {

+ 106 - 0
apps/desktop/tests/profile-core-cleanup.spec.ts

@@ -0,0 +1,106 @@
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { load } from 'js-yaml'
+import { cleanProfileCorePackages } from '../src/profile-core-cleanup.ts'
+
+const roots: string[] = []
+const core = '@deepseek-ai/dsh-web-app'
+const extra = '@deepseek-ai/optional-plugin'
+
+function fixture(): string {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-core-cleanup-'))
+  roots.push(root)
+  mkdirSync(join(root, 'node_modules', core), { recursive: true })
+  mkdirSync(join(root, 'node_modules', extra), { recursive: true })
+  writeFileSync(join(root, 'package.json'), JSON.stringify({
+    dependencies: { [core]: 'file:./desktop-packages/web.tgz', [extra]: '1.0.0' },
+    optionalDependencies: { [core]: '1.0.0' },
+    pnpm: { overrides: { [core]: '1.0.0', third: '2.0.0' } },
+    dsh: { profile: { bundles: [core, extra] } },
+  }))
+  writeFileSync(join(root, 'pnpm-workspace.yaml'), `nodeLinker: hoisted\noverrides:\n  '${core}': file:./desktop-packages/web.tgz\n  third: 2.0.0\n`)
+  writeFileSync(join(root, 'pnpm-lock.yaml'), 'old resolutions\n')
+  writeFileSync(join(root, 'cordis.patch.yml'), '[]\n')
+  return root
+}
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+it('removes core packages and reinstall declarations while preserving optional plugins and configuration', () => {
+  const root = fixture()
+  cleanProfileCorePackages(root, [core], true)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(false)
+  expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
+  expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))).toEqual({
+    dependencies: { [extra]: '1.0.0' }, optionalDependencies: {},
+    pnpm: { overrides: { third: '2.0.0' } }, dsh: { profile: { bundles: [core, extra] } },
+  })
+  expect(load(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ nodeLinker: 'hoisted', overrides: { third: '2.0.0' } })
+  expect(readFileSync(join(root, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
+  expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(false)
+  writeFileSync(join(root, 'pnpm-lock.yaml'), 'new plugin resolutions\n')
+  cleanProfileCorePackages(root, [core], true)
+  expect(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')).toBe('new plugin resolutions\n')
+})
+
+it('does not clean development profiles', () => {
+  const root = fixture()
+  const before = readFileSync(join(root, 'package.json'), 'utf8')
+  cleanProfileCorePackages(root, [core], false)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(true)
+  expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
+  expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(true)
+})
+
+it('unlinks development fallbacks without deleting their target, including dangling links', () => {
+  const root = fixture()
+  const target = join(root, 'development-package')
+  mkdirSync(target)
+  writeFileSync(join(target, 'sentinel'), 'keep')
+  const owned = join(root, '.dsh-module-fallback', 'node_modules', core)
+  mkdirSync(join(owned, '..'), { recursive: true })
+  symlinkSync(target, owned, 'junction')
+  rmSync(join(root, 'node_modules', core), { recursive: true })
+  symlinkSync(owned, join(root, 'node_modules', core), 'junction')
+  cleanProfileCorePackages(root, [core], true)
+  expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('keep')
+  expect(existsSync(owned)).toBe(false)
+  symlinkSync(target, join(root, 'node_modules', core), 'junction')
+  rmSync(target, { recursive: true })
+  cleanProfileCorePackages(root, [core], true)
+  expect(() => lstatSync(join(root, 'node_modules', core))).toThrow()
+})
+
+it('uses the old package inventory to remove retired core names', () => {
+  const root = fixture()
+  const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', core].sort()
+  writeFileSync(join(root, 'desktop-packages.json'), JSON.stringify({ schemaVersion: 1, packages: names.map((name, index) => ({
+    name, version: '0.1.2', file: `${index}.tgz`, bytes: 1, integrity: 'sha512-YQ==',
+  })) }))
+  cleanProfileCorePackages(root, [], true)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(false)
+  expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
+})
+
+it('rejects invalid metadata before deleting packages or declarations', () => {
+  const root = fixture()
+  writeFileSync(join(root, 'desktop-packages.json'), '{"schemaVersion":1,"packages":[{"name":"../../outside"}]}')
+  const before = readFileSync(join(root, 'package.json'), 'utf8')
+  expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('invalid package record')
+  expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
+  expect(existsSync(join(root, 'node_modules', core))).toBe(true)
+})
+
+it('refuses redirected package parents without deleting their contents', () => {
+  const root = fixture()
+  const target = join(root, 'external-scope')
+  mkdirSync(join(target, 'dsh-web-app'), { recursive: true })
+  rmSync(join(root, 'node_modules', '@deepseek-ai'), { recursive: true })
+  symlinkSync(target, join(root, 'node_modules', '@deepseek-ai'), 'junction')
+  expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('not a real directory')
+  expect(existsSync(join(target, 'dsh-web-app'))).toBe(true)
+})

+ 69 - 55
apps/desktop/tests/project-manager.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -71,19 +71,27 @@ afterEach(async () => {
 })
 
 describe('desktop external plugin profile', () => {
-  it('reuses plugin files without scanning manifests and can disable or reset them', async () => {
+  it('cleans application packages only when preparing a production launch', async () => {
+    const { manager } = setup()
+    await manager.applyRelease()
+    const name = '@deepseek-ai/dsh-web-app'
+    const path = join(manager.paths.profile, 'node_modules', name)
+    mkdirSync(path, { recursive: true })
+    await manager.applyRelease()
+    expect(existsSync(path)).toBe(true)
+    await manager.applyRelease(true)
+    expect(existsSync(path)).toBe(false)
+  })
+  it('reuses plugin files without scanning manifests and can disable them', 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()).resolves.toBeUndefined()
-    await manager.mutate({ type: 'plugins-disable-all' }, hooks())
+    await manager.disableAllPlugins()
     await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(readFileSync(manifest, 'utf8')).toBe('{broken')
-    await manager.resetConfiguration(hooks())
-    expect(existsSync(manifest)).toBe(false)
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
   it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
@@ -92,11 +100,7 @@ describe('desktop external plugin profile', () => {
     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((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
-        dsh: { profile: { bundles: string[] } }
-      }).dsh.profile.bundles).not.toContain('plugin')
-    } }))
+    await manager.disableAllPlugins()
     expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
       dsh: { profile: { bundles: string[] } }
     }).dsh.profile.bundles).not.toContain('plugin')
@@ -105,46 +109,40 @@ describe('desktop external plugin profile', () => {
     await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
-  it('resets the entire profile without backups while retaining its lock and shared data', async () => {
-    const { root, manager } = setup()
+  it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
+    const { 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.toThrow()
-    await manager.resetConfiguration(hooks({
-      beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
-      afterChange: async () => {
-        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(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toContain('[]')
-    expect(existsSync(join(profile, '.env'))).toBe(false)
-    expect(existsSync(join(profile, '.extra'))).toBe(false)
-    expect(existsSync(join(profile, 'external-link'))).toBe(false)
-    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(1)
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
-    expect(existsSync(homeEnvironment)).toBe(true)
+    const patch = join(manager.paths.profile, 'cordis.patch.yml')
+    writeFileSync(patch, ': broken')
+    const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
+    await uninitialized.disableAllPlugins()
+    expect(readFileSync(patch, 'utf8')).toBe(': broken')
+    expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
+    const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
+      dependencies: Record<string, string>
+      dsh: { profile: { bundles: string[] } }
+    }
+    expect(manifest.dependencies.plugin).toBe('1.0.0')
+    expect(manifest.dsh.profile.bundles).not.toContain('plugin')
+    expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
+  })
+
+  it('needs no runtime or package manifest when no plugins have been installed', async () => {
+    const { manager } = setup()
+    await manager.disableAllPlugins()
+    expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
+    expect(existsSync(manager.paths.lock)).toBe(false)
+  })
+
+  it('reports invalid profile JSON without replacing it', async () => {
+    const { manager } = setup()
+    await manager.applyRelease()
+    const path = join(manager.paths.profile, 'package.json')
+    writeFileSync(path, '{broken')
+    await expect(manager.disableAllPlugins()).rejects.toThrow()
+    expect(readFileSync(path, 'utf8')).toBe('{broken')
+    expect(existsSync(manager.paths.lock)).toBe(false)
   })
 
   it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => {
@@ -219,7 +217,6 @@ describe('desktop external plugin profile', () => {
     const { manager } = setup()
     writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
     await expect(manager.applyRelease()).rejects.toThrow()
-    expect(manager.canRecoverProfile()).toBe(false)
   })
 
   it('preserves unknown files when initializing a profile', async () => {
@@ -290,7 +287,7 @@ describe('desktop external plugin profile', () => {
     const { root, manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    await manager.mutate({ type: 'plugins-disable-all' }, hooks())
+    await manager.disableAllPlugins()
     expect(calls(root)).toHaveLength(1)
     expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
     await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks())
@@ -338,7 +335,7 @@ describe('desktop external plugin profile', () => {
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
     await expect(next.applyRelease()).resolves.toBeUndefined()
     expect(next.dshVersion()).toBe('2.0.0')
-    await next.mutate({ type: 'plugins-disable-all' }, hooks())
+    await next.disableAllPlugins()
     expect(next.dshVersion()).toBe('2.0.0')
     expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
   })
@@ -370,16 +367,33 @@ describe('desktop external plugin profile', () => {
       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(starts).toBe(1)
     expect(existsSync(manager.paths.lock)).toBe(false)
     writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { starts++ } }))
-    expect(starts).toBe(1)
+    await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } }))
+    expect(starts).toBe(2)
     await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
     expect(manager.listPlugins()).toEqual([])
   })
 
+  it('retains both package and restart errors when the changed profile cannot start', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    const failingPnpm = join(root, 'failing-restart.mjs')
+    writeFileSync(failingPnpm, 'process.exitCode = 1')
+    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
+    await worker.applyRelease()
+    const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') })
+    const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange }))
+      .catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(AggregateError)
+    expect((failure as AggregateError).errors.map((error: Error) => error.message))
+      .toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed'])
+    expect(afterChange).toHaveBeenCalledOnce()
+    expect(existsSync(manager.paths.lock)).toBe(false)
+  })
+
   it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
     const { root, manager } = setup()
     await manager.applyRelease()

+ 0 - 175
apps/desktop/tests/startup-renderer.spec.ts

@@ -1,175 +0,0 @@
-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' })
-  onTestFinished(() => {
-    dom.window.dispatchEvent(new dom.window.Event('pagehide'))
-    dom.window.close()
-  })
-  const listeners = new Set<(state: DesktopBackendState) => void>()
-  const unsubscribe = vi.fn(() => { listeners.clear() })
-  const disablePlugins = vi.fn(async () => {})
-  const resetConfiguration = vi.fn(async () => {})
-  const restart = vi.fn(async () => {})
-  const queried = Promise.withResolvers<undefined>()
-  const api: DshDesktopStartupApi = {
-    protocolVersion: 1,
-    locale: async () => resolveDesktopLocale(locale),
-    backend: {
-      status: () => { queried.resolve(undefined); return status },
-      subscribe: (listener) => { listeners.add(listener); return unsubscribe },
-    },
-    disablePlugins, resetConfiguration, restart,
-  }
-  Object.defineProperty(dom.window, 'dshDesktop', { value: api })
-  runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
-  const document = dom.window.document
-  const element = (selector: string): HTMLElement => {
-    const result = document.querySelector<HTMLElement>(selector)
-    if (result === null) throw new Error(`Missing startup element: ${selector}`)
-    return result
-  }
-  const button = (selector: string): HTMLButtonElement => {
-    const result = document.querySelector<HTMLButtonElement>(selector)
-    if (result === null) throw new Error(`Missing startup button: ${selector}`)
-    return result
-  }
-  const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) }
-  const copy = (): string => [element('#title').textContent, element('#description').textContent,
-    ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden)
-      .flatMap(selector => selector === '#actions'
-        ? [...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,
-    disablePlugins, resetConfiguration, restart, unsubscribe, queried: queried.promise }
-}
-
-it('shows English loading and recovery actions without a Host document', async () => {
-  const page = startup()
-  await expect.poll(() => page.element('#title').textContent).not.toBe('')
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "Starting DeepSeek Harness…
-    Your workspace will open when it is ready."
-  `)
-  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('#restart').disabled).toBe(true)
-  expect(page.button('#disable-plugins').disabled).toBe(true)
-  page.publish({ phase: 'error', profileRecovery: true, message: 'Plugin failed to load' })
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "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.
-    Plugin failed to load
-    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('#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')
-})
-
-it('shows Chinese loading and recovery copy', async () => {
-  const page = startup('zh-CN')
-  await expect.poll(() => page.element('#title').textContent).not.toBe('')
-  expect(page.document.documentElement.lang).toBe('zh-CN')
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "正在启动 DeepSeek Harness…
-    准备就绪后将自动打开工作区。"
-  `)
-  page.publish({ phase: 'error', profileRecovery: true, message: '插件加载失败' })
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "DeepSeek Harness 无法启动
-    请选择下方的恢复操作。禁用第三方插件会保留插件文件。
-    重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
-    如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
-    插件加载失败
-    关闭并重启
-    禁用全部第三方插件并重试
-    重置 Desktop 并重试"
-  `)
-})
-
-it('renders diagnostic markup as text and exposes failures from recovery actions', async () => {
-  const page = startup()
-  await expect.poll(() => page.element('#title').textContent).not.toBe('')
-  const diagnostic = '<img src=x onerror="window.compromised=true">'
-  page.publish({ phase: 'error', profileRecovery: true, message: diagnostic })
-  expect(page.element('#error').textContent).toBe(diagnostic)
-  expect(page.element('#error').childElementCount).toBe(0)
-  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('#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 () => {
-  const initial = Promise.withResolvers<DesktopBackendState>()
-  const page = startup('en', initial.promise)
-  await page.queried
-  page.publish({ phase: 'error', profileRecovery: true, message: 'Fresh startup failure' })
-  initial.resolve({ phase: 'starting' })
-  await initial.promise
-  expect(page.element('#error').textContent).toBe('Fresh startup failure')
-  expect(page.element('#actions').hidden).toBe(false)
-  page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide'))
-  expect(page.unsubscribe).toHaveBeenCalledOnce()
-  page.publish({ phase: 'starting' })
-  expect(page.element('#error').textContent).toBe('Fresh startup failure')
-  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)
-  page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' })
-  await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-profile.txt`, import.meta.url)))
-  for (const action of ['#disable-plugins', '#reset-configuration', '#restart']) {
-    page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' })
-    page.button(action).click()
-    expect(page.element('#actions').hidden).toBe(true)
-  }
-  expect(page.disablePlugins).toHaveBeenCalledOnce()
-  expect(page.resetConfiguration).toHaveBeenCalledOnce()
-  expect(page.restart).toHaveBeenCalledOnce()
-  page.publish({ phase: 'error', profileRecovery: false, message: 'Failure details' })
-  expect(page.button('#disable-plugins').hidden).toBe(true)
-  expect(page.button('#reset-configuration').hidden).toBe(true)
-  await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-restart.txt`, import.meta.url)))
-})
-
-it('keeps emergency diagnostics inert without shell assets', () => {
-  const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '<script>alert(1)</script>', true)
-  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()
-})
-
-it('offers only restart before emergency profile recovery is available', () => {
-  const dom = new JSDOM(startupFailureDocument(resolveDesktopLocale('en'), 'Resources unavailable'))
-  expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual(['dsh-recovery://restart'])
-  dom.window.close()
-})

+ 29 - 19
apps/web/src/main.ts

@@ -1,29 +1,39 @@
 /** Browser entry for the Web client. */
 import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web'
 
-const el = document.getElementById('root')
-if (el === null) throw new Error('web app: missing #root')
-const entry = new AppWebEntry(el)
 interface DesktopBootGlobal {
   dshDesktopBoot?: {
+    failed(message: string): Promise<void>
     ready(): Promise<{ injections: Parameters<typeof applyIndexInjections>[0]; streamBaseUrl: string }>
   }
 }
 const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot
-if (desktop !== undefined) {
-  const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers<void> }).__DSH_BOOT_READY__
-  if (gate === undefined) throw new Error('desktop web: boot readiness is missing')
-  void desktop.ready().then(async ({ injections, streamBaseUrl }) => {
-    const transport = globalThis as { __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string } }
-    transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }
-    await applyIndexInjections(injections, src => new Promise<void>((resolve, reject) => {
-      const script = document.createElement('script')
-      script.src = src
-      script.onload = () => { resolve() }
-      script.onerror = () => { reject(new Error(`desktop web: failed to load ${src}`)) }
-      document.head.append(script)
-    }))
-    gate.resolve()
-  }).catch((error: unknown) => { gate.reject(error) })
+const reportFailure = (reason: unknown): void => {
+  if (desktop === undefined) throw reason
+  void desktop.failed(reason instanceof Error ? reason.message : String(reason)).catch(console.error)
+}
+
+try {
+  const el = document.getElementById('root')
+  if (el === null) throw new Error('web app: missing #root')
+  const entry = new AppWebEntry(el)
+  if (desktop !== undefined) {
+    const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers<void> }).__DSH_BOOT_READY__
+    if (gate === undefined) throw new Error('desktop web: boot readiness is missing')
+    void desktop.ready().then(async ({ injections, streamBaseUrl }) => {
+      const transport = globalThis as { __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string } }
+      transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }
+      await applyIndexInjections(injections, src => new Promise<void>((resolve, reject) => {
+        const script = document.createElement('script')
+        script.src = src
+        script.onload = () => { resolve() }
+        script.onerror = () => { reject(new Error(`desktop web: failed to load ${src}`)) }
+        document.head.append(script)
+      }))
+      gate.resolve()
+    }).catch((error: unknown) => { gate.reject(error) })
+  }
+  void entry.run(desktop === undefined ? undefined : reportFailure)
+} catch (reason) {
+  reportFailure(reason)
 }
-void entry.run()

+ 64 - 0
apps/web/tests/desktop-boot.spec.ts

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+
+const boot = vi.hoisted(() => ({
+  run: vi.fn(),
+  applyIndexInjections: vi.fn(async () => {}),
+}))
+vi.mock('@deepseek-ai/dsh-client-web', () => ({
+  AppWebEntry: class { run = boot.run },
+  applyIndexInjections: boot.applyIndexInjections,
+}))
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.clearAllMocks()
+  vi.resetModules()
+  document.body.replaceChildren()
+})
+
+it('forwards client initialization failures to native recovery', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  const gate = Promise.withResolvers<undefined>()
+  const failed = vi.fn(async () => {})
+  vi.stubGlobal('__DSH_BOOT_READY__', gate)
+  vi.stubGlobal('dshDesktopBoot', { ready: async () => ({ injections: [], streamBaseUrl: 'http://127.0.0.1:3080' }), failed })
+  vi.stubGlobal('__DSH_TRANSPORT__', undefined)
+  await import('../src/main.ts')
+  await gate.promise
+  expect(boot.applyIndexInjections).toHaveBeenCalledWith([], expect.any(Function))
+  const report = boot.run.mock.calls[0]![0] as (reason: unknown) => void
+  report(new Error('client plugin activation failed'))
+  expect(failed).toHaveBeenCalledWith('client plugin activation failed')
+})
+
+it('rejects the shared boot wait when the Desktop Host cannot start', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  const gate = Promise.withResolvers<undefined>()
+  const failure = new Error('Host failed')
+  const rejected = expect(gate.promise).rejects.toBe(failure)
+  vi.stubGlobal('__DSH_BOOT_READY__', gate)
+  vi.stubGlobal('dshDesktopBoot', { ready: async () => { throw failure }, failed: vi.fn() })
+  await import('../src/main.ts')
+  await rejected
+  expect(boot.applyIndexInjections).not.toHaveBeenCalled()
+  expect(boot.run).toHaveBeenCalledWith(expect.any(Function))
+})
+
+it('leaves browser failure presentation with the Web boot kernel', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  vi.stubGlobal('dshDesktopBoot', undefined)
+  await import('../src/main.ts')
+  expect(boot.run).toHaveBeenCalledWith(undefined)
+})
+
+it.each(['root', 'readiness'])('reports missing %s before client plugin startup', async (missing) => {
+  if (missing !== 'root') document.body.innerHTML = '<div id="root"></div>'
+  const failed = vi.fn(async () => {})
+  vi.stubGlobal('dshDesktopBoot', { ready: vi.fn(), failed })
+  vi.stubGlobal('__DSH_BOOT_READY__', undefined)
+  await import('../src/main.ts')
+  expect(failed).toHaveBeenCalledWith(missing === 'root'
+    ? 'web app: missing #root' : 'desktop web: boot readiness is missing')
+  expect(boot.run).not.toHaveBeenCalled()
+})

+ 2 - 2
packages/client/web/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/web/README.md
-README.md: d3258b0f6f96fb84a5e58519f3e3313ad4bb022b
-README.zh.md: 7c66b2e4143505000b72ca097a341139b1c30c84
+README.md: 2cedd1748973e13ad1b9a3b0ef89c0101d4ffb09
+README.zh.md: 882c338339b725f82aae4d931ab85a6d14650b42

+ 1 - 1
packages/client/web/README.md

@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
 
 Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch, while explicit `seams` still win (for example jsdom tests, where external `<script>` execution cannot reach the page context).
 
-Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure. Desktop and WebWorker share this interpreter. Static boot consumes structured rows; server-side `tapIndex` HTML transforms apply only to served documents.
+Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure unless the caller supplies `run(onFailure)` to present the error externally while retaining the loading page. Desktop uses this callback to request native recovery. Desktop and WebWorker share the injection interpreter; server-side `tapIndex` HTML transforms apply only to served documents.
 
 The shell base styles apply automatic CJK/Latin spacing to ordinary content in supporting browsers. Semantic code and terminal, diff, read, and search output containers retain literal source spacing and column alignment; browsers without `text-autospace` support ignore both declarations.
 

+ 1 - 1
packages/client/web/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-library"
 
 组装浏览器应用时使用它:`apps/web` 的 Vite 入口对挂载点运行 `new AppWebEntry(container).run()`,启动页会在激活过程中向用户展示进度。普通浏览器调用方不传任何选项。默认使用预注入的页面传输,除非提供 `seams` 覆盖:当 `globalThis.__DSH_TRANSPORT__` 携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 `immediately` 层级的 HTTP 预取,而显式 `seams` 仍然优先(例如外部 `<script>` 执行无法到达页面上下文的 jsdom 测试)。
 
-静态应用页面在入口运行前设置 `__DSH_BOOT_READY__`。启动页立即显示,`run()` 保持等待;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入,并在所有脚本完成后解除等待。等待被拒绝时显示启动失败。Desktop 与 WebWorker 共享这个解释器。静态启动使用结构化注入;服务端 `tapIndex` HTML 转换只作用于服务端提供的文档。
+静态应用页面在入口运行前安装 `__DSH_BOOT_READY__`。`run()` 等待期间会立即显示启动页;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入项,并在所有脚本完成后兑现延迟对象。延迟对象拒绝时显示启动失败;若调用方提供 `run(onFailure)`,则由外部呈现错误并保留加载页。Desktop 使用该回调请求原生恢复。Desktop 与 WebWorker 共享注入解释器;服务端 `tapIndex` HTML 转换仅适用于服务端提供的文档。
 
 外壳基础样式会在支持的浏览器中为普通内容自动添加中西文间距。语义化代码以及终端、diff、读取和搜索输出容器会保留源码中的原始间距和列对齐;不支持 `text-autospace` 的浏览器会忽略这两项声明。
 

+ 8 - 4
packages/client/web/src/boot.ts

@@ -41,9 +41,10 @@ export class AppWebEntry {
   /**
    * Load and activate every client entry, then hand the mount point to the
    * UI renderer. Plugin failures remain visible on the boot page.
-   * @returns Resolves after application mount or failure rendering.
+   * @param onFailure - Optional carrier-owned fatal presentation; keeps the boot page visible.
+   * @returns Resolves after application mount or failure reporting.
    */
-  async run(): Promise<void> {
+  async run(onFailure?: (reason: unknown) => void): Promise<void> {
     try {
       // Boot-readiness gate: whichever bootstrap applies the injection table
       // settles this deferred once every row has taken effect — the served
@@ -82,12 +83,15 @@ export class AppWebEntry {
         ctx,
         modules: this.modules,
         manifest: this.manifest,
-        onEntryState: (name, state) => { this.page.setState(name, state) },
+        onEntryState: (name, state) => {
+          if (onFailure === undefined || state !== 'failed') this.page.setState(name, state)
+        },
       })
       await mountClient(ctx, this.container)
     } catch (reason) {
       console.error(reason)
-      this.page.fail(reason instanceof Error ? reason.message : String(reason))
+      if (onFailure !== undefined) onFailure(reason)
+      else this.page.fail(reason instanceof Error ? reason.message : String(reason))
     }
   }
 

+ 47 - 0
packages/client/web/tests/boot.client.spec.ts

@@ -56,6 +56,53 @@ async function expectBootFailure(setup: () => void, message: string): Promise<vo
 }
 
 describe('bootstrap failure rendering', () => {
+  it('leaves the loading page visible when a carrier owns fatal presentation', async () => {
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+    const container = document.createElement('div')
+    document.body.append(container)
+    const entry = new AppWebEntry(container)
+    const failure = vi.fn()
+    await entry.run(failure)
+    expect(failure).toHaveBeenCalledWith(new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing'))
+    expect(container.textContent).toContain('Loading plugins')
+    expect(container.textContent).not.toContain('Failed to load plugins')
+    expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
+    await entry.dispose()
+  })
+
+  it.each([false, true])('routes failed plugin activation with carrier presentation=%s', async (carrier) => {
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+    const container = document.createElement('div')
+    document.body.append(container)
+    const target = installFacade()
+    win.__DSH_BOOT__ = {
+      rev: 'graph',
+      entries: [{ id: 'broken', url: '/broken.js', rev: '1' }],
+      batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: ['broken'] }],
+    }
+    const entry = new AppWebEntry(container, {
+      loadBundle: async () => {
+        target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) })
+      },
+    })
+    const report = vi.fn<(reason: unknown) => void>()
+    try {
+      await entry.run(carrier ? report : undefined)
+      if (carrier) {
+        expect(report).toHaveBeenCalledOnce()
+        expect(String(report.mock.calls[0]![0])).toContain('broken')
+        expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
+        expect(container.textContent).not.toContain('Failed to load plugins')
+      } else {
+        expect(report).not.toHaveBeenCalled()
+        expect(container.textContent).toContain('Failed to load plugins')
+        expect(container.textContent).toContain('broken')
+      }
+    } finally {
+      await entry.dispose()
+    }
+  })
+
   it('renders a missing bootstrap facade', async () => {
     await expectBootFailure(
       () => { delete win.__ModuleLoader__ },

+ 13 - 21
pnpm-lock.yaml

@@ -5,6 +5,7 @@ settings:
   excludeLinksFromLockfile: false
 
 overrides:
+  extract-zip>yauzl: 3.4.0
   '@deepseek-ai/cosmokit': link:vendor/cosmokit
   '@deepseek-ai/schemastery': link:vendor/schemastery
 
@@ -565,6 +566,9 @@ importers:
       extract-zip:
         specifier: ^2.0.1
         version: 2.0.1(supports-color@9.4.0)
+      fflate:
+        specifier: ^0.8.2
+        version: 0.8.3
       js-yaml:
         specifier: ^4.2.0
         version: 4.3.1
@@ -6082,7 +6086,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@puppeteer/browsers':
         specifier: 3.2.2
-        version: 3.2.2(yauzl@2.10.0)
+        version: 3.2.2(yauzl@3.4.0)
       zod:
         specifier: ^4.4.3
         version: 4.4.3
@@ -16015,9 +16019,6 @@ packages:
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
 
-  buffer-crc32@0.2.13:
-    resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
-
   buffer-equal-constant-time@1.0.1:
     resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
 
@@ -16848,9 +16849,6 @@ packages:
     resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==}
     hasBin: true
 
-  fd-slicer@1.1.0:
-    resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
-
   fdir@6.5.0:
     resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
     engines: {node: '>=12.0.0'}
@@ -19488,8 +19486,9 @@ packages:
     resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==}
     engines: {node: ^20.19.0 || ^22.12.0 || >=23}
 
-  yauzl@2.10.0:
-    resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+  yauzl@3.4.0:
+    resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
+    engines: {node: '>=12'}
 
   yocto-queue@0.1.0:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
@@ -21477,12 +21476,12 @@ snapshots:
 
   '@publint/pack@0.1.4': {}
 
-  '@puppeteer/browsers@3.2.2(yauzl@2.10.0)':
+  '@puppeteer/browsers@3.2.2(yauzl@3.4.0)':
     dependencies:
       modern-tar: 0.8.5
       yargs: 18.1.0
     optionalDependencies:
-      yauzl: 2.10.0
+      yauzl: 3.4.0
 
   '@quansync/fs@1.0.0':
     dependencies:
@@ -22837,8 +22836,6 @@ snapshots:
       node-releases: 2.0.51
       update-browserslist-db: 1.2.3(browserslist@4.28.6)
 
-  buffer-crc32@0.2.13: {}
-
   buffer-equal-constant-time@1.0.1: {}
 
   buffer-from@1.1.2: {}
@@ -23790,7 +23787,7 @@ snapshots:
     dependencies:
       debug: 4.4.3(supports-color@9.4.0)
       get-stream: 5.2.0
-      yauzl: 2.10.0
+      yauzl: 3.4.0
     optionalDependencies:
       '@types/yauzl': 2.10.3
     transitivePeerDependencies:
@@ -23824,10 +23821,6 @@ snapshots:
       path-expression-matcher: 1.5.0
       strnum: 2.4.0
 
-  fd-slicer@1.1.0:
-    dependencies:
-      pend: 1.2.0
-
   fdir@6.5.0(picomatch@4.0.4):
     optionalDependencies:
       picomatch: 4.0.4
@@ -26792,10 +26785,9 @@ snapshots:
       y18n: 5.0.8
       yargs-parser: 22.0.0
 
-  yauzl@2.10.0:
+  yauzl@3.4.0:
     dependencies:
-      buffer-crc32: 0.2.13
-      fd-slicer: 1.1.0
+      pend: 1.2.0
 
   yocto-queue@0.1.0: {}
 

+ 1 - 0
pnpm-workspace.yaml

@@ -15,6 +15,7 @@ packages:
   - python/sdk-runtime
 
 overrides:
+  'extract-zip>yauzl': '3.4.0'
   '@deepseek-ai/cosmokit': 'link:vendor/cosmokit'
   '@deepseek-ai/schemastery': 'link:vendor/schemastery'