Przeglądaj źródła

fix(desktop): make startup recovery and package rebuilds retryable

07akioni 1 miesiąc temu
rodzic
commit
d55b2b3dad
40 zmienionych plików z 356 dodań i 274 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 13 15
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 13 15
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  5. 5 5
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  6. 5 5
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  8. 6 6
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  9. 6 6
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  11. 2 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  12. 2 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  13. 2 2
      apps/desktop/README.i18n.yaml
  14. 12 6
      apps/desktop/README.md
  15. 12 6
      apps/desktop/README.zh.md
  16. 3 1
      apps/desktop/renderer/startup.js
  17. 8 1
      apps/desktop/scripts/prepare-dsh.ts
  18. 67 0
      apps/desktop/scripts/smoke-windows.ps1
  19. 2 2
      apps/desktop/src/backend-controller.ts
  20. 2 5
      apps/desktop/src/ipc.ts
  21. 0 8
      apps/desktop/src/locale.ts
  22. 19 37
      apps/desktop/src/main.ts
  23. 0 3
      apps/desktop/src/preload-app.ts
  24. 5 16
      apps/desktop/src/profile-packages.ts
  25. 23 24
      apps/desktop/src/project-manager.ts
  26. 5 4
      apps/desktop/src/startup-document.ts
  27. 5 23
      apps/desktop/src/startup-error.ts
  28. 4 4
      apps/desktop/tests/backend-controller.spec.ts
  29. 0 8
      apps/desktop/tests/expected/startup-en-plugins.txt
  30. 0 0
      apps/desktop/tests/expected/startup-en-profile.txt
  31. 0 8
      apps/desktop/tests/expected/startup-en-reinstall.txt
  32. 0 3
      apps/desktop/tests/expected/startup-en-restart.txt
  33. 0 8
      apps/desktop/tests/expected/startup-zh-CN-plugins.txt
  34. 0 0
      apps/desktop/tests/expected/startup-zh-CN-profile.txt
  35. 0 8
      apps/desktop/tests/expected/startup-zh-CN-reinstall.txt
  36. 0 3
      apps/desktop/tests/expected/startup-zh-CN-restart.txt
  37. 43 3
      apps/desktop/tests/main-startup.spec.ts
  38. 2 5
      apps/desktop/tests/preload-app.spec.ts
  39. 59 4
      apps/desktop/tests/project-manager.spec.ts
  40. 23 22
      apps/desktop/tests/startup-renderer.spec.ts

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: 2e266b74c2c1a5de62312ddce2c43de0908b2675
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0a48e80b47450db7e45ab96f3a7b6e465ca271b6
+2026-08-25-electron-desktop-packaging-and-updates.md: f92f15542b1903cdfe5c7b2794ce872becab3517
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: e67d00417fb4e80cff742862537572653ad8d22c

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
 
-Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
 
 ## Problem
 
@@ -28,7 +28,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
 
 | Owner | Responsibility |
 |---|---|
-| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback |
+| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination |
 | Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state |
 | Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision |
 | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
@@ -43,10 +43,6 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
 ```text
 ~/.dsh/
   desktop/
-    staging/<transaction-id>/profile/
-    rollback/profile/
-    pending.json
-    lock
     pnpm/
       store/
       cache/
@@ -56,6 +52,8 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
     desktop/
       package.json
       pnpm-lock.yaml
+      lock
+      desktop-packages-pending
       pnpm-workspace.yaml
       desktop-runtime-state.json
       node_modules/
@@ -67,9 +65,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
 
 ## Installation and resolution
 
-The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Activation stops the active backend, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
+Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md).
 
-The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
+The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered.
 
 Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts.
 
@@ -79,7 +77,7 @@ Electron update uses one `electron-updater` release stream and signed `electron-
 
 The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md).
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations.
+`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations.
 
 ## Security and release policy
 
@@ -109,10 +107,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 |---|---|
 | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
 | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. |
-| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in Desktop staging. |
+| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. |
 | Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
 
-`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, runtime preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.
+`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, dsh preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.
 
 ## Alternatives considered
 
@@ -146,7 +144,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 - The backend and browser application cannot mutate desktop packages.
 - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
 - The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads.
-- Failed installation, backend activation, or update leaves the current profile usable or restores `rollback/profile` after restart.
+- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised.
 - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
 - Shared `.dsh` data rejects incompatible readers before migration or mutation.
 - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
@@ -162,7 +160,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 | First launch | Check bundled release metadata and create profile links without installing core dependencies |
 | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links |
 | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
-| Activation | Staging project, journaled directory replacement, actual Host startup, one rollback copy |
+| Activation | In-place package changes followed by actual Host startup |
 | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
 | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
 
@@ -170,11 +168,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 
 Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships.
 
-Updating the bound dsh can invalidate plugin peer dependencies or native modules. Dependency validation rejects incompatible metadata before replacement; actual Host startup can fail after replacement and trigger profile rollback.
+Updating the bound dsh can invalidate plugin peer dependencies or native modules. Reconciliation validates changed dependencies and rebuilds native packages; failures require explicit repair through the recovery UI.
 
 An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
 
-Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move.
+Interrupted package operations retain a pending marker. Installed-artifact tests must verify that a later launch retries the locked installation and approved native builds.
 
 Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification.
 

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
 
-本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
 ## 问题
 
@@ -28,7 +28,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 | Owner | 职责 |
 |---|---|
-| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 |
+| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调 |
 | 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
 | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
 | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
@@ -43,10 +43,6 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 ```text
 ~/.dsh/
   desktop/
-    staging/<transaction-id>/profile/
-    rollback/profile/
-    pending.json
-    lock
     pnpm/
       store/
       cache/
@@ -56,6 +52,8 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
     desktop/
       package.json
       pnpm-lock.yaml
+      lock
+      desktop-packages-pending
       pnpm-workspace.yaml
       desktop-runtime-state.json
       node_modules/
@@ -67,9 +65,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 ## 安装与解析
 
-安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。激活过程停止活跃后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
+Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
-进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
+进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。
 
 核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。
 
@@ -79,7 +77,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 [立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。
+`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。
 
 ## 安全与发布策略
 
@@ -109,10 +107,10 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 |---|---|
 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
 | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 |
-| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop staging 中的外部插件依赖图。 |
+| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
 | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
 
-`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、运行时准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
+`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
 
 ## 考虑过的替代方案
 
@@ -146,7 +144,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 - 后端与浏览器应用不能修改桌面包。
 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
 - 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
-- 安装、后端激活或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。
+- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
@@ -162,7 +160,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 | 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 |
 | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
 | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
-| 激活 | 暂存项目、记录式目录替换、实际 Host 启动和一个回滚副本 |
+| 激活 | 直接修改包后启动实际 Host |
 | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
 | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
 
@@ -170,11 +168,11 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 
 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。
 
-更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。依赖验证在替换前拒绝不兼容的元数据;实际 Host 启动可能在替换后失败,并触发 profile 回滚。
+更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。校准会验证变化的依赖并重建原生包;失败后需要通过恢复 UI 显式修复。
 
 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
 
-不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。
+中断的包操作保留未完成标记。已安装产物测试必须验证后续启动会重试锁定依赖的安装和获准的原生构建。
 
 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 9fcee1d49d4d2595fa9dfc929b58ee3e5262ac54
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: f448029cbf840e8003ce26d67788663168629da4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
 
-Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
 
 ## Problem
 
@@ -34,15 +34,15 @@ The profile manifest records exact installed plugin dependencies separately from
 
 ## Transactions and upgrades
 
-First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, and validates enabled peers. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile.
+First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links and validate enabled peers in place. Node version, platform, or architecture changes reinstall the locked plugin graph and run approved native builds.
 
-Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary staging and backend replacement. Transaction cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
+Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
 
 Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction.
 
-The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes.
+Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and persistent retry state. Recorded host links identify owned directories independently of package-operation completion.
 
-The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window, replacing staged backend probes. Users can open plugin management to update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Each backend launch requires the current runtime identity.
 
 ## Alternatives considered
 

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
 
-本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
 ## 问题
 
@@ -34,15 +34,15 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 
 
 ## 事务与升级
 
-首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,并验证已启用的 peer。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。
+首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建。
 
-共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 staging 和后端替换。事务清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
+共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
 
 依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。
 
-事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。
+Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立。
 
-[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host 和主窗口恢复,取代 staging 后端探针。用户可以打开插件管理,更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。
 
 ## 考虑过的替代方案
 

+ 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: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d
+2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
 
-Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active.
+Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
 
 ## Problem
 
@@ -12,22 +12,22 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 ## Decision
 
-Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit.
+Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; 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. Every error page retains diagnostics and exposes restart, disable-third-party-plugins, and reset-Desktop actions, plus reinstallation guidance. The actions remain available regardless of error classification. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The lock lives inside the profile, and reset preserves its directory so another transaction cannot acquire a replacement lock during cleanup. Disabled third-party package files cannot block startup when no third-party bundles are enabled. Self-contained shell recovery controls use intercepted form navigation so a failed preload cannot disable them.
+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 only in a packaged application with loaded runtime metadata and available resources. 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.
 
-Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes.
+Desktop starts the actual Host after preparing the profile in place, without booting a separate health-check backend. Package mutations retain dependency validation, approved lifecycle builds, runtime identity checks, and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 
 This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
 
 ## Alternatives considered
 
-**Keep a complete staged health check.** It can reject a startup failure before replacing the active profile, but executes plugin initialization twice and cannot guarantee that the subsequent serving process will start. The actual startup result and journaled rollback provide recovery without the extra probe.
+**Keep a complete staged health check.** It can reject some startup failures before activation, but executes plugin initialization twice and cannot guarantee that the serving process will start. The actual Host result provides the diagnostic needed for explicit recovery.
 
 **Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails.
 
 ## Consequences
 
-Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Activation can fail after profile replacement; the journal retains recovery for profile files.
+Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails.
 
 Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests.

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
 
-本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。
+profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
 ## 问题
 
@@ -12,22 +12,22 @@ Status: implemented
 
 ## 决策
 
-Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断和恢复操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。
+Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
-主窗口负责恢复,因为故障 Host 无法提供自己的操作界面。每个错误页保留诊断,提供重启、禁用第三方插件和重置 Desktop 三个操作,以及重装提示。所有操作均不依赖错误分类而保持可用。重置删除 profile 内除当前持有的锁之外的全部内容,不留备份;共享产品数据和 Harness home 环境文件保持不变。锁位于 profile 内,重置保留目录,防止其他事务在清理期间取得替代锁。没有启用第三方 bundle 时,已禁用插件的文件不能阻止启动。独立内嵌错误页通过拦截表单导航执行恢复,因此 preload 故障不会使按钮失效。
+主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
 
-profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。
+Desktop 直接准备 profile 后启动实际 Host,不另行启动健康检查后端。包修改保留依赖验证、获准生命周期构建、运行时标识检查和锁。失败后保留部分修改,供显式修复;不自动回滚 profile。
 
 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
 
 ## 考虑过的替代方案
 
-**保留完整 staging 健康检查。** 它可以在替换活动 profile 前拒绝启动失败,但会执行两次插件初始化,也不能保证后续服务进程能够启动。实际启动结果与记录式回滚无需额外探针即可提供恢复能力。
+**保留完整 staging 健康检查。** 它可以在激活前拒绝部分启动失败,但会执行两次插件初始化,也不能保证服务进程能够启动。实际 Host 结果提供显式恢复所需的诊断。
 
 **在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。
 
 ## 后果
 
-产品 UI 可用前,用户就能看到启动进度并从失败中恢复。窗口可响应并不表示后端已就绪,启动延迟仍需测量安装产物。激活可能在替换 profile 后失败;事务日志保留 profile 文件的恢复能力。
+用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
 
 验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。

+ 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: c83bb9826a4068f699e330f911c52064790b08b6
-2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8
+2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385
+2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287

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

@@ -16,6 +16,8 @@ Package or Host failures retain partial changes for repair and retry. There is n
 
 This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active.
 
+A persistent `desktop-packages-pending` marker precedes package writes or native-runtime rebuilding and is removed only after installation, approved builds, and validation succeed. A later launch with that marker reinstalls the locked graph and retries pending builds even when recorded runtime metadata already matches. Ordinary unchanged startups reuse the profile without scanning the plugin dependency graph; package mutations and runtime reconciliation retain validation.
+
 ## Alternatives considered
 
 Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs.

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

@@ -16,6 +16,8 @@ Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共
 
 本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。
 
+持久的 `desktop-packages-pending` 标记先于包写入或原生运行时重建,仅在安装、获准构建和验证成功后删除。后续启动发现该标记时,会重新安装锁定的依赖图并重试待执行构建,即使记录的运行时元数据已经匹配。普通未变化的启动复用 profile,不扫描插件依赖图;包修改和运行时校准保留验证。
+
 ## 考虑过的替代方案
 
 staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。

+ 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: 91cdd6d4ea0e001dd9ae81dc9f8e64aa5a912879
-README.zh.md: 6bfddb6fec4a8b525991a9f3cc4eb7f1a431d6b8
+README.md: 62d592307161f202f156d66d91f5a4663c3facc1
+README.zh.md: ace1497876d8b60612b8b5adf1d3ee86f1beeaa4

+ 12 - 6
apps/desktop/README.md

@@ -23,7 +23,7 @@ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/a
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `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/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The local startup page exposes startup status, retry, and plugin-management actions; the loaded dsh renderer receives only the desktop protocol marker. 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 local startup page exposes startup status and available recovery actions; the loaded dsh renderer receives only the desktop protocol marker. 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.
 
 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.
 
@@ -31,15 +31,15 @@ Electron chooses typed English or Chinese shell copy from its application locale
 
 The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
-1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once.
+1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
 2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
-5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error; repair the installation and retry. Desktop creates no staging directories, activation journals, or rollback copies.
+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. Unfinished package operations retain a marker so the next launch retries the locked installation and pending builds. Desktop creates no staging directories, activation journals, or rollback copies.
 
-The loading page does not depend on the Host. Every startup and backend error page retains diagnostics and offers three actions: restart the application, disable all third-party plugins and restart the Host, or reset Desktop and restart the Host. Reinstallation guidance is always visible for missing or damaged application files. Disabling plugins retains their files and skips their installation graph when none are enabled. The plugin manager remains available through the application menu. Runtime compatibility is checked before any backend starts; plugin changes have no automatic rollback.
+The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered only when packaged application resources support profile recovery; development and early initialization failures expose restart alone. The plugin manager remains available through the application menu. Runtime identity is checked before any backend starts; plugin changes have no automatic rollback.
 
-Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the same three actions and diagnostics; its controls do not require preload.
+Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
 
 Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
 
@@ -96,7 +96,13 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
 
 Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
 
-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; the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) exercises the resulting native modules and HTML converter under the bundled Node.
+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 the bundled Node 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, installer scratch cleanup, 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
+```
 
 ### Upload updates
 

+ 12 - 6
apps/desktop/README.zh.md

@@ -23,7 +23,7 @@
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-本地启动页提供启动状态、重试和插件管理操作;加载后的 dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
+本地启动页提供启动状态和可用恢复操作;加载后的 dsh 渲染进程仅接收桌面协议标记。独立插件窗口接收结构化的列表、安装、删除、更新和更新检查操作;两个渲染进程都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
 Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
 
@@ -31,15 +31,15 @@ Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,
 
 签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest(元数据清单)和共享包链接,然后激活并启动一次实际后端。
+1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。
 2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
-5. 插件变更在修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败保留已修改文件并报告错误;修复安装后重试。Desktop 不创建 staging 目录、激活日志或回滚副本。
+5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。未完成的包操作保留标记,使下次启动重试锁定依赖的安装和待执行构建。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
-加载页不依赖 Host。每个启动和后端错误页均保留诊断,并提供三个操作:重启应用、禁用全部第三方插件并重启 Host、重置 Desktop 并重启 Host。页面始终显示应用文件缺失或损坏时的重装提示。禁用插件会保留文件;没有启用的第三方插件时,不再检查它们的安装依赖图。插件管理仍可从应用菜单打开。每次启动后端前都会检查运行时兼容性;插件变更不自动回滚。
+加载页不依赖 Host。错误页提供重启和重装指导。只有已打包应用的资源支持 profile 恢复时,才提供禁用插件和重置 Desktop;开发模式和早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。每次后端启动前都会检查运行时标识;插件修改不自动回滚。
 
-重置会删除 `$DSH_HOME/profiles/desktop` 内除当前持有的事务锁之外的全部内容,再初始化内置 profile。Desktop 配置和已安装第三方插件会被删除,不保留备份。共享任务、设置和 Harness home 的 `.env` 不受影响。壳资源或 preload 故障使用独立内嵌页面,保留相同的三个操作和诊断;其按钮不依赖 preload。
+重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
 
 包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
 
@@ -96,7 +96,13 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
 
-打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)使用内置 Node 验证过滤后的原生模块和 HTML 转换器。
+打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用内置 Node 执行[产物 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 清理、安装器临时目录清理和两种文件占用替换方式;不属于单元测试通道。
+
+```powershell
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+```
 
 ### 上传更新
 

+ 3 - 1
apps/desktop/renderer/startup.js

@@ -19,7 +19,9 @@ async function main() {
     document.querySelector('#error').textContent = failed ? state.message : ''
     document.querySelector('#actions').hidden = !failed
     for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
-    document.querySelector('#reset-advice').hidden = !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

+ 8 - 1
apps/desktop/scripts/prepare-dsh.ts

@@ -1,6 +1,6 @@
 /** Materialize the complete production runtime before publishing Desktop resources. */
 
-import { spawn } from 'node:child_process'
+import { spawn, execFile } from 'node:child_process'
 import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { delimiter, dirname, join, relative, resolve } from 'node:path'
@@ -138,6 +138,13 @@ async function main(): Promise<void> {
     }
     writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
     const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
+    await new Promise<void>((accept, reject) => {
+      execFile(NODE, [join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT],
+        { timeout: 120_000, env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => {
+          if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error }))
+          else { process.stdout.write(stdout); accept() }
+        })
+    })
     await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor)
     await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
   } catch (error) {

+ 67 - 0
apps/desktop/scripts/smoke-windows.ps1

@@ -0,0 +1,67 @@
+# Run native Electron cleanup and NSIS 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'
+
+function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
+  & $Executable @Arguments | Out-Host
+  if ($LASTEXITCODE -ne 0) { throw "$Executable exited with $LASTEXITCODE" }
+}
+
+try {
+  $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 }
+
+  $cleanupExe = Join-Path $scratch 'cleanup.exe'
+  $cleanupResult = Join-Path $scratch 'cleanup.txt'
+  Invoke-Checked $Makensis @('/V2', "/DOUTPUT_FILE=$cleanupExe", "/DRESULT_FILE=$cleanupResult", (Join-Path $fixtureRoot 'installer-cleanup-smoke.nsi'))
+  Invoke-Checked $cleanupExe @('/S')
+  if ((Get-Content -LiteralPath $cleanupResult -Raw) -ne 'scratch removed; archive, plugin, rollback, registers and error flags preserved') {
+    throw 'NSIS cleanup did not preserve its sentinels'
+  }
+
+  $payload = Join-Path $scratch 'payload'
+  New-Item -ItemType Directory -Path $payload | Out-Null
+  [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
+  [System.IO.File]::WriteAllText((Join-Path $payload 'asset.txt'), 'new asset')
+  $archive = Join-Path $scratch 'payload.7z'
+  Invoke-Checked $SevenZip @('a', '-bd', '-t7z', $archive, "$payload/*")
+  foreach ($mode in @('copy', 'direct')) {
+    $target = Join-Path $scratch $mode
+    New-Item -ItemType Directory -Path $target | Out-Null
+    $locked = Join-Path $target 'locked.txt'
+    [System.IO.File]::WriteAllText($locked, 'old runtime')
+    $probeExe = Join-Path $scratch "$mode.exe"
+    $probeResult = Join-Path $scratch "$mode.txt"
+    $compileArgs = @('/V2', "/DOUTPUT_FILE=$probeExe", "/DRESULT_FILE=$probeResult", "/DPAYLOAD_FILE=$archive", "/DTARGET_DIR=$target", "/DPLUGIN_DIR=$PluginDir")
+    if ($mode -eq 'direct') { $compileArgs += '/DDIRECT' }
+    $compileArgs += Join-Path $fixtureRoot 'installer-write-failure-smoke.nsi'
+    Invoke-Checked $Makensis $compileArgs
+    $handle = [System.IO.File]::Open($locked, 'Open', 'Read', 'Read')
+    try { Invoke-Checked $probeExe @('/S') }
+    finally { $handle.Dispose() }
+    $flag = if ($mode -eq 'copy') { 'true' } else { 'false' }
+    $expected = "errorFlag=$flag`nlocked=old runtime`nasset=new asset"
+    if ((Get-Content -LiteralPath $probeResult -Raw).Replace("`r`n", "`n").TrimEnd() -ne $expected) {
+      throw "Unexpected NSIS $mode replacement result"
+    }
+  }
+  Write-Output 'Electron cleanup and NSIS cleanup/replacement smokes passed.'
+} finally {
+  $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
+  $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)
+  if (-not $resolvedScratch.StartsWith($tempRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
+    throw "Refusing cleanup outside the temporary root: $resolvedScratch"
+  }
+  Remove-Item -LiteralPath $resolvedScratch -Recurse -Force
+}

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

@@ -1,9 +1,9 @@
 /** Owns one backend startup and its quiescent teardown independently of windows. */
 
-import { desktopErrorState, type DesktopRecovery } from './startup-error.ts'
+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 recovery?: DesktopRecovery }
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean }
 
 /** Child lifecycle owned by the desktop backend controller. */
 export interface DesktopBackendHost {

+ 2 - 5
apps/desktop/src/ipc.ts

@@ -8,7 +8,6 @@ import type { DesktopBackendState } from './backend-controller.ts'
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   pluginsList: 'dsh-desktop:plugins-list',
-  pluginsOpen: 'dsh-desktop:plugins-open',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
   pluginsUpdate: 'dsh-desktop:plugins-update',
@@ -17,7 +16,6 @@ export const DESKTOP_IPC = {
   backendStatus: 'dsh-desktop:backend-status',
   backendRetry: 'dsh-desktop:backend-retry',
   applicationRestart: 'dsh-desktop:application-restart',
-  applicationClose: 'dsh-desktop:application-close',
   configurationReset: 'dsh-desktop:configuration-reset',
   backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
@@ -57,10 +55,9 @@ export interface DshDesktopApi {
 }
 
 /** Startup-page controls, unavailable to backend-provided application documents. */
-export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale' | 'backend'> {
-  openPlugins(): Promise<void>
+export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale'> {
+  readonly backend: Omit<DshDesktopApi['backend'], 'retry'>
   disablePlugins(): Promise<void>
   restart(): Promise<void>
-  close(): Promise<void>
   resetConfiguration(): Promise<void>
 }

+ 0 - 8
apps/desktop/src/locale.ts

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

+ 19 - 37
apps/desktop/src/main.ts

@@ -20,12 +20,13 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
-import { desktopErrorState, DesktopStartupError } from './startup-error.ts'
+import { desktopErrorState } from './startup-error.ts'
 import { startupFailureDocument } from './startup-document.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'))
@@ -35,16 +36,12 @@ let recoverApplication = (action: RecoveryAction): Promise<void> => {
 }
 
 async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
-  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message)
+  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 errorOf(reason: unknown, fallback: string): Error {
-  return reason instanceof Error ? reason : new Error(fallback)
-}
-
 protocol.registerSchemesAsPrivileged([{
   scheme: SCHEME,
   privileges: {
@@ -112,6 +109,7 @@ function createWindow(preload: string, show = false): BrowserWindow {
     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}`)
@@ -155,6 +153,7 @@ async function main(): Promise<void> {
   const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project')
   const activeProject = development ?? paths.profile
   const manager = new DesktopProjectManager(paths, resources)
+  profileRecoveryAvailable = () => development === undefined && manager.canRecoverProfile()
   let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
   let quitting = false
   let startup: Promise<void> | undefined
@@ -175,7 +174,7 @@ async function main(): Promise<void> {
     if (quitting || emergencyDocument) return
     emergencyDocument = true
     const diagnostic = desktopErrorState(error).message
-    pageError = { phase: 'error', message: diagnostic, recovery: 'reinstall' }
+    pageError = { phase: 'error', message: diagnostic }
     if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
   }
 
@@ -192,8 +191,10 @@ async function main(): Promise<void> {
     navigation = next
     return next.promise
   }
-  const backendState = (): DesktopBackendState => pageError === undefined
-    ? backend.state : pageError
+  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)
@@ -202,26 +203,10 @@ async function main(): Promise<void> {
   const backend = new DesktopBackendController((onFailure) => {
     if (development === undefined) manager.assertProfileRuntime(activeProject)
     const hostInspectPort = developmentHostInspectPort(development !== undefined)
-    const pluginsEnabled = development === undefined && manager.hasEnabledPlugins()
-    const classify = (error: Error): Error => {
-      if (error instanceof DesktopStartupError) return error
-      if ((error.message.includes(resources.node) || error.message.includes(resources.dsh))
-        && /ENOENT|Cannot find|failed to (?:read|parse) overlay/u.test(error.message)) {
-        return new DesktopStartupError('reinstall', error)
-      }
-      if (error.message.includes('only the launching environment may set')
-        || /failed to (?:read|parse) (?:overlay|patches)|must be a top-level YAML array|patchReload must be/u.test(error.message)) {
-        return new DesktopStartupError('configuration', error)
-      }
-      return pluginsEnabled ? new DesktopStartupError('plugins', error) : error
-    }
-    const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env,
-      (error) => { onFailure(classify(error)) })
+    const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject,
+      hostInspectPort, process.env, onFailure)
     return {
-      async start() {
-        try { return await host.start() }
-        catch (error) { throw classify(errorOf(error, messages.startupFailed)) }
-      },
+      start: () => host.start(),
       stop: () => host.stop(),
       fetch: (request: Request) => host.fetch(request),
     }
@@ -252,7 +237,7 @@ async function main(): Promise<void> {
       app.quit()
       return
     }
-    if (development !== undefined) throw new Error('Desktop profile recovery requires a packaged application')
+    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
@@ -363,10 +348,6 @@ async function main(): Promise<void> {
     await reconcileBackend()
     focusPrimaryWindow()
   })
-  ipcMain.handle(DESKTOP_IPC.applicationClose, (event) => {
-    assertDesktopSender(event, ['shell'])
-    app.quit()
-  })
   ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
     assertDesktopSender(event, ['shell'])
     try {
@@ -452,10 +433,6 @@ async function main(): Promise<void> {
     pluginWindow.once('closed', () => { pluginWindow = undefined })
     void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
   }
-  ipcMain.handle(DESKTOP_IPC.pluginsOpen, (event) => {
-    assertDesktopSender(event, ['shell'])
-    openPluginWindow()
-  })
 
   Menu.setApplicationMenu(Menu.buildFromTemplate([{
     label: process.platform === 'darwin' ? app.name : messages.application,
@@ -480,6 +457,8 @@ async function main(): Promise<void> {
       void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
     })
     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) })
     })
@@ -536,4 +515,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk
   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)
+}).catch((error: unknown) => {
+  console.error(error)
+  app.exit(1)
 })

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

@@ -9,17 +9,14 @@ const startup: DshDesktopStartupApi = {
   locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
   backend: {
     status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['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) }
     },
   },
-  openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise<void>,
   disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
   restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
-  close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise<void>,
   resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
 }
 

+ 5 - 16
apps/desktop/src/profile-packages.ts

@@ -5,7 +5,6 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathS
 import { createRequire } from 'node:module'
 import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import { satisfies } from 'semver'
-import { DesktopStartupError } from './startup-error.ts'
 import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 
 /** Applied runtime identity and the only links Desktop may replace. */
@@ -17,7 +16,7 @@ export interface DesktopPackageLink {
   readonly target: string
 }
 
-/** Facts from the last successfully validated profile. */
+/** Runtime identity and managed links; package preparation may still be pending. */
 export interface DesktopProfileState {
   readonly schemaVersion: 1
   readonly runtimeId: string
@@ -53,11 +52,6 @@ function inside(root: string, path: string): boolean {
  * @returns Validated state, or undefined for an uninitialized profile.
  */
 export function readDesktopProfileState(profile: string): DesktopProfileState | undefined {
-  try { return readProfileState(profile) }
-  catch (error) { throw new DesktopStartupError('configuration', error) }
-}
-
-function readProfileState(profile: string): DesktopProfileState | undefined {
   const path = join(profile, DESKTOP_PROFILE_STATE)
   if (!existsSync(path)) return undefined
   const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
@@ -179,8 +173,7 @@ export function validateDesktopPluginGraph(
 ): void {
   const profileRoot = realpathSync.native(profile)
   const shared = new Map(runtime.sharedPackages.map((entry) => {
-    try { return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const }
-    catch (error) { throw new DesktopStartupError('reinstall', error) }
+    return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const
   }))
   for (const [name, path] of shared) {
     if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
@@ -238,12 +231,8 @@ export function validateDesktopPluginGraph(
     }
   }
   for (const name of activePlugins) {
-    try {
-      const path = packageFrom(profile, name)
-      if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
-      visit(path, name)
-    } catch (error) {
-      throw new DesktopStartupError('plugins', error)
-    }
+    const path = packageFrom(profile, name)
+    if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
+    visit(path, name)
   }
 }

+ 23 - 24
apps/desktop/src/project-manager.ts

@@ -26,7 +26,6 @@ import {
 import type { DesktopPaths } from './paths.ts'
 import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
-import { DesktopStartupError } from './startup-error.ts'
 import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
   desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
@@ -123,11 +122,11 @@ function assertVersion(version: string): void {
 }
 
 /**
- * Validate one registry package spec and return its requested package name when explicit.
+ * Validate one registry package spec and return its package name.
  * @param spec - npm registry name with an optional version or tag.
- * @returns package name, or undefined when the spec's final name is registry-resolved.
+ * @returns Requested package name.
  */
-export function packageNameFromSpec(spec: string): string | undefined {
+export function packageNameFromSpec(spec: string): string {
   if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) {
     throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`)
   }
@@ -148,11 +147,6 @@ export function packageNameFromSpec(spec: string): string | undefined {
 }
 
 function projectManifest(projectDir: string): DesktopProjectManifest {
-  try { return readProjectManifest(projectDir) }
-  catch (error) { throw new DesktopStartupError('configuration', error) }
-}
-
-function readProjectManifest(projectDir: string): DesktopProjectManifest {
   const path = join(projectDir, 'package.json')
   const value = readJson(path)
   const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined
@@ -173,11 +167,11 @@ function readProjectManifest(projectDir: string): DesktopProjectManifest {
 function profilePluginNames(projectDir: string): readonly string[] {
   const bundles = projectManifest(projectDir).dsh.profile.bundles
   if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) {
-    throw new DesktopStartupError('configuration', new Error('desktop project: profile must begin with the built-in desktop bundle list'))
+    throw new Error('desktop project: profile must begin with the built-in desktop bundle list')
   }
   const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length)
   if (new Set(bundles).size !== bundles.length) {
-    throw new DesktopStartupError('configuration', new Error('desktop project: profile bundle list contains a duplicate package'))
+    throw new Error('desktop project: profile bundle list contains a duplicate package')
   }
   for (const plugin of plugins) assertPackageName(plugin)
   return plugins
@@ -284,19 +278,27 @@ export class DesktopProjectManager {
 
   /** Reject a profile whose dependency links were prepared for another runtime. */
   assertProfileRuntime(projectDir: string): void {
+    if (existsSync(this.pendingPackages)) throw new Error('desktop project: package preparation is incomplete; retry startup')
     if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) {
       throw new Error('desktop project: profile does not match this application runtime')
     }
   }
 
+  /** @returns Whether application resources support profile recovery. */
+  canRecoverProfile(): boolean {
+    return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
+  }
+
+  private get pendingPackages(): string { return join(this.paths.profile, 'desktop-packages-pending') }
+
   private currentRuntime(): DesktopRuntimeDescriptor {
     if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
     return this.descriptor
   }
 
   private readRuntime(): DesktopRuntimeDescriptor {
-    try { return readDesktopRuntime(this.runtime.dsh) }
-    catch (error) { throw new DesktopStartupError('reinstall', error) }
+    this.descriptor = undefined
+    return readDesktopRuntime(this.runtime.dsh)
   }
 
   private prepareProfile(projectDir: string): void {
@@ -311,16 +313,12 @@ export class DesktopProjectManager {
       const target = this.readRuntime()
       this.descriptor = target
       const previous = readDesktopProfileState(this.paths.profile)
-      if (previous === undefined && readdirSync(this.paths.profile).some(name => join(this.paths.profile, name) !== this.paths.lock)) {
-        throw new DesktopStartupError('configuration', new Error('desktop project: existing profile is not a Desktop plugin profile'))
-      }
-      if (previous?.runtimeId === desktopRuntimeId(target)
+      if (!existsSync(this.pendingPackages) && previous?.runtimeId === desktopRuntimeId(target)
         && previous.lockHash === desktopPluginLockHash(this.paths.profile)
         && previous.links.length === target.sharedPackages.length
         && previous.links.every(link => existsSync(link.target)
           && existsSync(join(this.paths.profile, 'node_modules', link.name))
           && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) {
-        validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile))
         return false
       }
       if (previous === undefined) createPluginProfile(this.paths.profile)
@@ -360,28 +358,30 @@ export class DesktopProjectManager {
 
   private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise<void> {
     const target = this.currentRuntime()
-    this.prepareProfile(projectDir)
-    const rebuild = previous !== undefined && pluginRecords(projectDir).length > 0
-      && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch)
+    const rebuild = (!packagesChanged && existsSync(this.pendingPackages))
+      || (previous !== undefined && pluginRecords(projectDir).length > 0
+      && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch))
     if (rebuild) {
+      writeFileSync(this.pendingPackages, '')
       unlinkDesktopHostPackages(projectDir)
       removeOwnedDirectory(join(projectDir, 'node_modules'))
       await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts'])
     }
     if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir)
+    else this.prepareProfile(projectDir)
   }
 
   private async finishPackageOperation(projectDir: string): Promise<void> {
     this.prepareProfile(projectDir)
     await this.runPnpm(projectDir, ['rebuild', '--pending'])
     this.prepareProfile(projectDir)
+    unlinkSync(this.pendingPackages)
   }
 
   private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
     switch (mutation.type) {
       case 'plugin-add': {
         const requestedName = packageNameFromSpec(mutation.spec)
-        if (requestedName === undefined) throw new Error('desktop project: plugin package name is required')
         if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) {
           throw new Error(`desktop project: cannot install host-owned package ${requestedName}`)
         }
@@ -445,6 +445,7 @@ export class DesktopProjectManager {
     const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
       name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
     )))
+    writeFileSync(this.pendingPackages, '')
     await new Promise<void>((settle, reject) => {
       const child = spawn(this.runtime.node, [
         this.runtime.pnpm,
@@ -581,7 +582,6 @@ export function createRuntimeProjectMetadata(projectDir: string, release: Deskto
     workspaceFile(desktopCorePackageOverrides(packageSet)),
     { mode: 0o600 },
   )
-  writeJson(join(projectDir, 'desktop-release.json'), release)
 }
 
 /**
@@ -603,7 +603,6 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De
   }
   writeJson(join(projectDir, 'package.json'), manifest)
   writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
-  writeJson(join(projectDir, 'desktop-release.json'), release)
 }
 
 /** Create the first external plugin profile without running a package manager. */

+ 5 - 4
apps/desktop/src/startup-document.ts

@@ -6,9 +6,10 @@ 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): string {
+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">
@@ -16,10 +17,10 @@ export function startupFailureDocument(locale: DesktopLocale, message: string):
 <title>${escape(locale.messages.startupFailed)}</title>
 <style>:root{color-scheme:light dark;font-family:system-ui}body{max-width:720px;margin:10vh auto;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
 <main><h1>${escape(locale.messages.startupFailed)}</h1><p>${escape(locale.messages.startupReinstallAdvice)}</p>
-<p>${escape(locale.messages.startupConfigurationAdvice)}</p>
+${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>
-<form action="dsh-recovery://plugins"><button>${escape(locale.messages.disableThirdPartyPlugins)}</button></form>
-<form action="dsh-recovery://reset"><button>${escape(locale.messages.resetConfiguration)}</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>`
 }

+ 5 - 23
apps/desktop/src/startup-error.ts

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

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

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

+ 0 - 8
apps/desktop/tests/expected/startup-en-plugins.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 - 0
apps/desktop/tests/expected/startup-en-configuration.txt → apps/desktop/tests/expected/startup-en-profile.txt


+ 0 - 8
apps/desktop/tests/expected/startup-en-reinstall.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 - 3
apps/desktop/tests/expected/startup-en-restart.txt

@@ -1,8 +1,5 @@
 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 - 8
apps/desktop/tests/expected/startup-zh-CN-plugins.txt

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

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


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

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

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

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

+ 43 - 3
apps/desktop/tests/main-startup.spec.ts

@@ -77,6 +77,7 @@ const harness = await vi.hoisted(async () => {
     dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     assertProfileRuntime: vi.fn(),
+    canRecoverProfile: vi.fn(() => true),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
@@ -108,7 +109,7 @@ vi.mock('../src/project-manager.ts', () => ({
   DesktopProjectManager: class {
     readonly applyRelease = harness.applyRelease
     readonly assertProfileRuntime = harness.assertProfileRuntime
-    hasEnabledPlugins() { return harness.pluginsEnabled }
+    canRecoverProfile = harness.canRecoverProfile
     async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
       await hooks.beforeChange()
       harness.pluginsEnabled = false
@@ -145,6 +146,8 @@ afterEach(async () => {
   for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
   harness.app.quit()
   await harness.quitCompleted.promise
+  vi.restoreAllMocks()
+  harness.canRecoverProfile.mockReturnValue(true)
   vi.clearAllTimers()
   vi.useRealTimers()
   vi.unstubAllEnvs()
@@ -152,6 +155,43 @@ afterEach(async () => {
 })
 
 describe('desktop main startup', () => {
+  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) })
+    await import('../src/main.ts')
+    await exited.promise
+    expect(harness.app.exit).toHaveBeenCalledWith(1)
+    expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' }))
+  })
+
+  it('withholds profile recovery after application resources fail to load', async () => {
+    harness.canRecoverProfile.mockReturnValue(false)
+    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')
+  })
+
+  it('reloads a crashed startup renderer in the same window', 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://shell/startup.html', 'dsh-app://shell/startup.html'])
+    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
+  })
+
   it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -214,7 +254,7 @@ describe('desktop main startup', () => {
     harness.hosts[0]!.exited.resolve()
     harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed'))
     await harness.errorPublished.promise
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ recovery: 'plugins' })
+    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true })
     const nextStarted = harness.nextHostStart()
     const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll))
     await nextStarted
@@ -293,7 +333,7 @@ describe('desktop main startup', () => {
     first.ready.reject(new Error('plugin composition failed'))
     await harness.errorPublished.promise
     await failedRetry
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', recovery: 'restart' })
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true })
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html'])
     const nextStarted = harness.nextHostStart()
     const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))

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

@@ -21,15 +21,12 @@ it('provides startup controls and a removable state subscription to shell docume
   const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi
   await api.locale()
   await api.backend.status()
-  await api.backend.retry()
-  await api.openPlugins()
   await api.disablePlugins()
   await api.resetConfiguration()
   await api.restart()
-  await api.close()
   expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
-    [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen],
-    [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], [DESKTOP_IPC.applicationClose],
+    [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)

+ 59 - 4
apps/desktop/tests/project-manager.spec.ts

@@ -70,13 +70,13 @@ afterEach(async () => {
 })
 
 describe('desktop external plugin profile', () => {
-  it('starts with disabled plugins even when their installed manifests are corrupt', async () => {
+  it('reuses plugin files without scanning manifests and can disable or reset 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()).rejects.toThrow()
+    await expect(manager.applyRelease()).resolves.toBe(false)
     await manager.mutate({ type: 'plugins-disable-all' }, hooks())
     await expect(manager.applyRelease()).resolves.toBe(false)
     expect(readFileSync(manifest, 'utf8')).toBe('{broken')
@@ -119,7 +119,7 @@ describe('desktop external plugin profile', () => {
     mkdirSync(shared)
     writeFileSync(join(shared, 'sentinel'), 'preserve')
     symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir')
-    await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' })
+    await expect(manager.applyRelease()).rejects.toThrow()
     await manager.resetConfiguration(hooks({
       beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
       afterChange: async () => {
@@ -146,7 +146,8 @@ describe('desktop external plugin profile', () => {
   it('reports damaged application metadata as a reinstall failure', async () => {
     const { manager } = setup()
     writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
-    await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' })
+    await expect(manager.applyRelease()).rejects.toThrow()
+    expect(manager.canRecoverProfile()).toBe(false)
   })
 
   it('accepts registry names and tags but rejects alternate sources and flags', () => {
@@ -157,6 +158,58 @@ describe('desktop external plugin profile', () => {
     }
   })
 
+  it('retries installation after an interrupted runtime rebuild removed plugin files', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    const dsh = join(root, 'new-node')
+    runtimeFixture(dsh, '1.1.0', '24.18.0')
+    const failing = join(root, 'fail-install.mjs')
+    writeFileSync(failing, 'process.exitCode = 1')
+    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing })
+    await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1')
+    expect(existsSync(join(manager.paths.profile, 'node_modules/plugin'))).toBe(false)
+    const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
+    await expect(retry.applyRelease()).resolves.toBe(true)
+    expect(retry.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
+    await expect(retry.applyRelease()).resolves.toBe(false)
+  })
+
+  it('preserves unknown files when initializing a profile', async () => {
+    const { manager } = setup()
+    mkdirSync(manager.paths.profile, { recursive: true })
+    writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata')
+    writeFileSync(join(manager.paths.profile, 'user-file'), 'retain')
+    await expect(manager.applyRelease()).resolves.toBe(true)
+    expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata')
+    expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain')
+  })
+
+  it.each(['plugin-add', 'runtime-change'] as const)('retries failed rebuild after %s across manager instances', async (operation) => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    let dsh = manager.runtime.dsh
+    if (operation === 'runtime-change') {
+      await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+      dsh = join(root, 'new-node')
+      runtimeFixture(dsh, '1.1.0', '24.18.0')
+    }
+    const failing = join(root, 'fail-rebuild.mjs')
+    writeFileSync(failing, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); if (process.argv.includes('rebuild')) process.exitCode = 1`)
+    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing })
+    if (operation === 'plugin-add') {
+      await worker.applyRelease()
+      await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())).rejects.toThrow('pnpm exited with 1')
+    } else await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1')
+    expect(() => { worker.assertProfileRuntime(worker.paths.profile) }).toThrow('package preparation is incomplete')
+    const count = calls(root).length
+    const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
+    await expect(retry.applyRelease()).resolves.toBe(true)
+    expect(calls(root).slice(count).map(call => call.args.find(arg => !arg.startsWith('--config.')))).toEqual(['install', 'rebuild'])
+    await expect(retry.applyRelease()).resolves.toBe(false)
+    expect(calls(root)).toHaveLength(count + 2)
+  })
+
   it('initializes and restarts offline without executing pnpm', async () => {
     const { root, manager } = setup()
     await expect(manager.applyRelease()).resolves.toBe(true)
@@ -207,6 +260,8 @@ describe('desktop external plugin profile', () => {
     expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true)
     expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
     await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u)
+    await expect(manager.applyRelease()).resolves.toBe(false)
+    expect(calls(root)).toHaveLength(2)
   })
 
   it('retains disabled plugin versions through updates and enables them explicitly', async () => {

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

@@ -16,23 +16,18 @@ function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.r
   })
   const listeners = new Set<(state: DesktopBackendState) => void>()
   const unsubscribe = vi.fn(() => { listeners.clear() })
-  const retry = vi.fn(async () => {})
-  const openPlugins = vi.fn(async () => {})
   const disablePlugins = vi.fn(async () => {})
   const resetConfiguration = vi.fn(async () => {})
   const restart = vi.fn(async () => {})
-  const close = vi.fn(async () => {})
   const queried = Promise.withResolvers<undefined>()
   const api: DshDesktopStartupApi = {
     protocolVersion: 1,
     locale: async () => resolveDesktopLocale(locale),
     backend: {
       status: () => { queried.resolve(undefined); return status },
-      retry,
       subscribe: (listener) => { listeners.add(listener); return unsubscribe },
     },
-    openPlugins,
-    disablePlugins, resetConfiguration, restart, close,
+    disablePlugins, resetConfiguration, restart,
   }
   Object.defineProperty(dom.window, 'dshDesktop', { value: api })
   runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
@@ -54,8 +49,8 @@ function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.r
         ? [...document.querySelectorAll<HTMLButtonElement>('#actions button')].filter(button => !button.hidden).map(button => button.textContent)
         : [element(selector).textContent]),
   ].join('\n')
-  return { dom, document, element, button, publish, copy, retry, openPlugins,
-    disablePlugins, resetConfiguration, restart, close, unsubscribe, queried: queried.promise }
+  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 () => {
@@ -70,7 +65,7 @@ it('shows English loading and recovery actions without a Host document', async (
   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', message: 'Plugin failed to load' })
+  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.
@@ -98,7 +93,7 @@ it('shows Chinese loading and recovery copy', async () => {
     "正在启动 DeepSeek Harness…
     准备就绪后将自动打开工作区。"
   `)
-  page.publish({ phase: 'error', message: '插件加载失败' })
+  page.publish({ phase: 'error', profileRecovery: true, message: '插件加载失败' })
   expect(page.copy()).toMatchInlineSnapshot(`
     "DeepSeek Harness 无法启动
     请选择下方的恢复操作。禁用第三方插件会保留插件文件。
@@ -115,7 +110,7 @@ it('renders diagnostic markup as text and exposes failures from recovery actions
   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', message: diagnostic })
+  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'))
@@ -129,7 +124,7 @@ it('keeps subscribed state when initial status arrives late and detaches on page
   const initial = Promise.withResolvers<DesktopBackendState>()
   const page = startup('en', initial.promise)
   await page.queried
-  page.publish({ phase: 'error', message: 'Fresh startup failure' })
+  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')
@@ -145,24 +140,24 @@ it('keeps subscribed state when initial status arrives late and detaches on page
 it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => {
   const page = startup(locale)
   await expect.poll(() => page.button('#restart').disabled).toBe(true)
-  for (const recovery of ['plugins', 'configuration', 'reinstall', 'restart'] as const) {
-    page.publish({ phase: 'error', recovery, message: 'Failure details' })
-    await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-${recovery}.txt`, import.meta.url)))
-    expect(page.button('#disable-plugins').hidden).toBe(false)
-    expect(page.button('#reset-configuration').hidden).toBe(false)
-    const action = recovery === 'plugins' ? '#disable-plugins' : recovery === 'configuration'
-      ? '#reset-configuration' : '#restart'
+  page.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.close).not.toHaveBeenCalled()
-  expect(page.restart).toHaveBeenCalledTimes(2)
+  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>')
+  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>')
@@ -172,3 +167,9 @@ it('keeps emergency diagnostics inert without shell assets', () => {
   ])
   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()
+})