Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into worktree/issue-3929-v41-request-image

# Conflicts:
#	packages/attachment/attachment-local/README.i18n.yaml
#	packages/attachment/attachment-local/README.md
#	packages/attachment/attachment-local/README.zh.md
creatixchu 3 săptămâni în urmă
părinte
comite
ffa536712a
100 a modificat fișierele cu 3144 adăugiri și 1493 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  2. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  3. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml
  5. 2 0
      .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md
  6. 2 0
      .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  8. 4 4
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  9. 4 5
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  11. 12 14
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  12. 12 14
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  14. 61 0
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  15. 61 0
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml
  17. 23 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md
  18. 23 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md
  19. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  20. 33 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  21. 33 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  22. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  23. 27 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  24. 27 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  25. 6 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml
  26. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
  27. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md
  28. 6 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  29. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  30. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  31. 6 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
  32. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
  33. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
  34. 6 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml
  35. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md
  36. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md
  37. 6 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml
  38. 90 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
  39. 90 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md
  40. 2 2
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml
  41. 1 1
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md
  42. 1 1
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md
  43. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  44. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  45. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  46. 6 0
      .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml
  47. 95 0
      .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
  48. 95 0
      .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md
  49. 1 0
      .agents/skills/dsh-pre-push-checks/SKILL.md
  50. 1 1
      .github/review-ownership/README.md
  51. 4 2
      .github/review-ownership/check-approval.mjs
  52. 17 1
      .github/review-ownership/check-approval.test.mjs
  53. 2 1
      THIRD_PARTY_NOTICES.md
  54. 1 0
      apps/cli/tests/web-agent-presets.e2e.ts
  55. 19 15
      apps/desktop-host/src/index.ts
  56. 2 2
      apps/desktop/README.i18n.yaml
  57. 52 32
      apps/desktop/README.md
  58. 52 32
      apps/desktop/README.zh.md
  59. 7 2
      apps/desktop/electron-builder.config.d.mts
  60. 29 9
      apps/desktop/electron-builder.config.mjs
  61. 3 3
      apps/desktop/package.json
  62. 6 0
      apps/desktop/renderer/plugin-manager.html
  63. 17 2
      apps/desktop/renderer/plugin-manager.js
  64. 16 0
      apps/desktop/renderer/startup.css
  65. 26 0
      apps/desktop/renderer/startup.html
  66. 45 0
      apps/desktop/renderer/startup.js
  67. 2 2
      apps/desktop/scripts/desktop-build-paths.d.mts
  68. 3 3
      apps/desktop/scripts/desktop-build-paths.mjs
  69. 3 4
      apps/desktop/scripts/dev.ts
  70. 17 0
      apps/desktop/scripts/installer.nsh
  71. 43 0
      apps/desktop/scripts/macos-runtime.ts
  72. 0 413
      apps/desktop/scripts/macos-seed-store.ts
  73. 30 6
      apps/desktop/scripts/package-target.ts
  74. 159 0
      apps/desktop/scripts/prepare-dsh.ts
  75. 0 200
      apps/desktop/scripts/prepare-seed.ts
  76. 40 0
      apps/desktop/scripts/runtime-file-policy.ts
  77. 58 0
      apps/desktop/scripts/smoke-runtime.ts
  78. 67 0
      apps/desktop/scripts/smoke-windows.ps1
  79. 6 6
      apps/desktop/scripts/verify-macos-signature.d.mts
  80. 11 11
      apps/desktop/scripts/verify-macos-signature.mjs
  81. 148 0
      apps/desktop/src/backend-controller.ts
  82. 1 1
      apps/desktop/src/core-package-set.ts
  83. 20 6
      apps/desktop/src/host-process.ts
  84. 23 0
      apps/desktop/src/ipc.ts
  85. 30 0
      apps/desktop/src/locale.ts
  86. 217 93
      apps/desktop/src/main.ts
  87. 26 0
      apps/desktop/src/owned-directory.ts
  88. 1 7
      apps/desktop/src/paths.ts
  89. 22 3
      apps/desktop/src/preload-app.ts
  90. 12 0
      apps/desktop/src/preload.ts
  91. 238 0
      apps/desktop/src/profile-packages.ts
  92. 194 308
      apps/desktop/src/project-manager.ts
  93. 2 2
      apps/desktop/src/release.ts
  94. 222 0
      apps/desktop/src/runtime-tree.ts
  95. 0 271
      apps/desktop/src/seed-store.ts
  96. 26 0
      apps/desktop/src/startup-document.ts
  97. 13 0
      apps/desktop/src/startup-error.ts
  98. 1 1
      apps/desktop/src/update-coordinator.ts
  99. 170 0
      apps/desktop/tests/backend-controller.spec.ts
  100. 3 3
      apps/desktop/tests/desktop-build-paths.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8c55c142137f0ab24869bd57ffed3835b7b0516a
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 52ca2e13901d9469e2f9663936acb766a934e8f7

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-07-24-single-harness-home-resolver.md
-2026-07-24-single-harness-home-resolver.md: 02578674b4547bae54b2edce99c6c4ef53748588
-2026-07-24-single-harness-home-resolver.zh.md: 5764be3f85901196cd2a2db77d1fc3838668163f
+2026-07-24-single-harness-home-resolver.md: 1191c52bfa222b807e9aa301b250d2efcc8b0953
+2026-07-24-single-harness-home-resolver.zh.md: cefa124b7cb46bab92157af34576f59d3053da9f

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md

@@ -23,6 +23,8 @@ explicit configured path  >  $DSH_HOME  >  ~/.dsh
 
 An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces agent-instructions's bespoke default-vs-`$DSH_HOME` check.
 
+`dshCachePath(...segments)` derives paths below the resolved home's `cache` directory. An initial `{ dshHome }` option preserves a provider's explicit home override. It resolves paths without creating directories; callers own directory creation. `attachment-local` uses this helper for regenerable request-image variants while retaining durable attachment objects in their versioned storage tree, so clearing the cache cannot remove Session attachments. Existing request-image cache entries are left in place and are not read or copied; a cache miss regenerates the variant from its durable attachment.
+
 `@deepseek-ai/dsh-home` is deleted. Home-owning providers and boot packages import `resolveDshHome` from `dsh-home-paths`; composition bundles contain only the resolved configuration rows.
 
 `dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy.

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md

@@ -23,6 +23,8 @@ explicit configured path  >  $DSH_HOME  >  ~/.dsh
 
 空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 agent-instructions 中自定义的「默认值 vs `$DSH_HOME`」判断。
 
+`dshCachePath(...segments)` 在解析出的主目录下的 `cache` 目录中派生路径。首个 `{ dshHome }` 选项保留提供方显式配置的主目录覆盖值。它只解析路径,不创建目录;目录创建由调用方负责。`attachment-local` 将此函数用于可重新生成的请求图片版本,持久附件对象仍保留在其带版本的存储树中,因此清空缓存不会删除 Session 附件。已有请求图片缓存条目保留在原处,不再读取或复制;缓存未命中时从持久附件重新生成请求版本。
+
 `@deepseek-ai/dsh-home` 被删除。拥有 home 配置的提供方与 boot 包从 `dsh-home-paths` 导入 `resolveDshHome`;组合包只包含解析后的配置行。
 
 `dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 14d568689a662e3e7d1fedf26c22aea6faddd56b
-2026-08-03-per-session-agent-presets.zh.md: 9b296fa7a1c2b9164d9e7a0ad676929179a64fef
+2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98
+2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md

@@ -27,11 +27,11 @@ The presets the deployment ships are the directories under `packages/preset/agen
 
 Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
 
-Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own.
+The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `default`. `modeSelectionEnabled` defaults to `true`: the existing new-session picker remains present and an unnamed session resolves to the saved user `default`, or the composition's deployment `default` when none exists. The Web Settings toggle changes only that policy: disabling selection temporarily uses the deployment default, while re-enabling it restores the saved user `default`. This is a deliberate exception to the ordinary user-over-composition settings precedence established in [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539): hiding the chooser disables the user's mode-selection policy without deleting its saved value. The Host policy governs every later session whose caller omits a preset; explicitly named presets and existing sessions remain unchanged. The composition value also keeps the package working with no settings provider, while an enabled user override changes later sessions without editing a deployment-owned `cordis.yml`.
 
 ## Consequences
 
-**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
+**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
 
 **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
 
@@ -61,11 +61,11 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
 
 **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
 
-**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
+**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so the client surfaces show what a session actually runs rather than the deployment's current default.
 
 **A durable header field is not durable until the provider writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same form — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.
 
-**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.
+**The choice belongs to the screen where it still works.** The control lives on the new-session screen beside the workspace picker, is present under the default-on Host policy, and disappears only after `modeSelectionEnabled` is disabled. Its pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it; hiding the picker discards a stage that has not reached a session and returns the current blank session to the deployment default through the same selection path. What a running or historical session runs remains a read-only label in its header: a control there would promise a switch the host refuses outright.
 
 **A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one.
 

+ 4 - 5
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md

@@ -27,12 +27,11 @@ Status: implemented
 
 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
 
-未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。
+`agent-presets` 用户设置命名空间同时携带 `modeSelectionEnabled` 与 `default`。`modeSelectionEnabled` 默认为 `true`:既有的新建会话选择器保持显示;未指名会话会解析到已保存的用户 `default`,尚未保存时则使用组装中 `default` 指定的部署默认值。Web 设置开关只改变该策略:关闭选择时临时使用部署默认值,再次开启时恢复已保存的用户 `default`。这是对 [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539) 所确立“用户值覆盖组装值”这一普通 settings 优先级的有意例外:隐藏选择器会停用用户的模式选择策略,但不会删除其保存值。该 Host 策略适用于此后所有未显式指定 preset 的会话;显式指定及既有会话不受影响。组装值还使本包在没有 settings 提供方时照常工作;选择器开启后,用户可覆盖默认值来改变后续会话,而无需编辑部署所拥有的 `cordis.yml`。
 
 ## 后果
 
-**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
-
+**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
 
 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
 
@@ -62,11 +61,11 @@ Status: implemented
 
 **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。
 
-**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。
+**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使客户端界面显示某个会话实际运行的 preset,而非部署当前的默认值。
 
 **持久化 header 字段在 provider 写入前都算不上持久。** `agentPreset` 带着正确理由落在 `SessionHeader` 上,而 JSONL provider 遗漏了它;派生 query index 也显式映射 header 字段,于是恢复后的 Session 没有 preset,所有据以命名它的 surface 随之失声。`summarizeCold` 是同一种形式——它手工拼装 cold list row,而没有复用共享 projection。声明为持久的字段,需要一个跨越真实 store 的测试,而不只是声明它的类型。
 
-**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
+**这个选择属于它仍然可用的那个界面。** 控件位于新建会话界面、工作区选择器旁边,在 Host 默认开启策略下直接显示,仅在 `modeSelectionEnabled` 关闭后隐藏。选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致;隐藏选择器会丢弃尚未到达会话的暂存选择,并通过同一条选择链路把当前空白会话带回部署默认值。至于运行中或历史会话在跑什么,仍由其标题旁的只读标签展示:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
 
 **preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。
 

+ 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: 6d22777c8913911dbb1d89c09de9e891636873c8
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c
+2026-08-25-electron-desktop-packaging-and-updates.md: f92f15542b1903cdfe5c7b2794ce872becab3517
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: e67d00417fb4e80cff742862537572653ad8d22c

Fișier diff suprimat deoarece este prea mare
+ 12 - 14
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


Fișier diff suprimat deoarece este prea mare
+ 12 - 14
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md


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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4

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

@@ -0,0 +1,61 @@
+# Agent Note: Bundle the Desktop runtime and retain external plugins
+
+Status: implemented
+
+English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
+
+Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+
+## Problem
+
+Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades.
+
+Separate package directories can load duplicate Cordis or service modules. Retaining plugin files also does not prove compatibility with a new host API or Node runtime.
+
+## Decision
+
+[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
+
+Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived.
+
+## Package ownership
+
+The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
+
+The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
+
+Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
+
+Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
+
+External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
+
+The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them.
+
+## Transactions and upgrades
+
+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 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.
+
+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. 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
+
+Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory.
+
+- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
+- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
+- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
+- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
+- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths.
+
+## Consequences
+
+Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
+
+The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.

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

@@ -0,0 +1,61 @@
+# Agent Note: 内置 Desktop 运行时并保留外部插件
+
+Status: implemented
+
+[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
+
+profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+
+## 问题
+
+Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。
+
+分离的包目录可能加载重复的 Cordis 或服务模块。仅保留插件文件也不能证明它与新的宿主 API 或 Node 运行时兼容。
+
+## 决策
+
+[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
+
+Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。
+
+## 包归属
+
+资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
+
+[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
+
+包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
+
+dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
+
+外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。
+
+profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。
+
+## 事务与升级
+
+首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建。
+
+共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
+
+依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。
+
+Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立。
+
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。
+
+## 考虑过的替代方案
+
+完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+
+- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
+- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
+- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
+- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
+- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。
+
+## 影响
+
+首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
+
+[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md
+2026-09-09-desktop-build-release-validation.md: 23aeeaa98d2647f8b9f54e94ea2aedaa82b19e55
+2026-09-09-desktop-build-release-validation.zh.md: 266bca5bbd593907c9a8fadd43050113ede94100

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

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

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

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

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
+2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542

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

@@ -0,0 +1,33 @@
+# Agent Note: Show the Desktop window before starting the Host
+
+Status: implemented
+
+English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
+
+Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+
+## Problem
+
+Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process.
+
+## 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; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
+
+The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available 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.
+
+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 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. 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.

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

@@ -0,0 +1,33 @@
+# Agent Note: Show the Desktop window before starting the Host
+
+Status: implemented
+
+[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
+
+profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+
+## 问题
+
+等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。
+
+## 决策
+
+Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
+
+主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
+
+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 健康检查。** 它可以在激活前拒绝部分启动失败,但会执行两次插件初始化,也不能保证服务进程能够启动。实际 Host 结果提供显式恢复所需的诊断。
+
+**在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。
+
+## 后果
+
+用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
+
+验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
+2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385
+2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287

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

@@ -0,0 +1,27 @@
+# Agent Note: Modify the Desktop profile in place
+
+Status: implemented
+
+English | [中文](2026-09-09-desktop-in-place-profile.zh.md)
+
+## Problem
+
+Staging preserves an old plugin installation but adds profile copying, directory moves, a recovery journal, and rollback state. Local plugin changes accept explicit repair after failure instead of this complexity.
+
+## Decision
+
+Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files.
+
+Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.
+
+This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active.
+
+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.
+
+## Consequences
+
+Tests cover offline initialization, in-place upgrades, failure before writes, retained changes after Host failure, partial pnpm failure, restored host links, and exclusive package ownership. Signed application and GUI acceptance remain release-environment checks.

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

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

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
+2026-09-10-command-identities-and-composer-file-action.md: 696c3486c5e007416da3fd5e7be1d4b41cb5f582
+2026-09-10-command-identities-and-composer-file-action.zh.md: 2ce803bad5d39a6d4f40b665fb4ef7456e987912

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md

@@ -0,0 +1,33 @@
+# Agent Note: Command identities and composer-owned File action
+
+Status: implemented
+
+English | [中文](2026-09-10-command-identities-and-composer-file-action.zh.md)
+
+## Problem
+
+Matching a command's English description to a client dictionary makes punctuation changes affect localization and inserted command tokens. A same-name override can also copy that description without implementing the first-party command. The File menu entry needs the composer's live attachment policy, including mount, lock, and submission state; a separate command-plugin check cannot determine that state.
+
+## Decision
+
+The command registry preserves an optional branded `CommandDefinitionId` as `definitionId` on the effective definition and descriptor. First-party producers choose their package name as the stable identity. Scoped shadowing selects the complete descriptor and never inherits the shadowed definition's identity. The identifier is discovery metadata, not an authorization claim, and does not enter command lifecycle events.
+
+The client command directory resolves input through its private `resolution.ts`. Exact registered names take priority; Chinese and English aliases select only the corresponding first-party definition in the effective Session catalog. Menu claims use the current locale's spelling; typed claims retain the supplied spelling; submissions use the resolved registered name. `presentation.ts` owns only sections, labels, descriptions, and icons. Resolution helpers and section constants are not exported from the plugin entrypoint.
+
+Conversation registers the File action through the injected command service and owns its localized label. The mounted input binds its file-dialog opener and one live availability query. Both menu filtering and invocation use that query, so lock, unmount, subagent, and submission state apply consistently. The binding and dispatch remain package-internal callbacks; no cross-plugin pick-files event is needed. The assembly uses a narrow structural action-registration face because command UI consumes Conversation's input types; a reverse compiler-project dependency would form a cycle. Its registration test checks against the command plugin's contribution type.
+
+This note supersedes only identity matching, input-resolution placement, and File-action ownership in the [composer menu decision](../feature/2026-09-08-composer-menu-sections-and-localized-rows.md). That note retains the menu, scrolling, claim-retention, and composition-timing decisions.
+
+## Alternatives considered
+
+**Match names and English descriptions.** Copy is editable and can be duplicated by unrelated definitions. A stable identity separates presentation selection from copy without moving localized text onto the Host.
+
+**Match names alone.** An agent-scoped override may deliberately provide a different command under the same name. It must not inherit first-party presentation or aliases unless it explicitly carries that identity.
+
+**Keep the File action in the command plugin.** The input owns attachment acceptance and the DOM lifetime. Maintaining a second availability condition splits one policy between owners.
+
+**Export helpers for reuse.** No production consumer needs the section constants or resolution helper as a plugin API. Tests import internal modules directly.
+
+## Consequences
+
+First-party producers and client identity mappings must agree on stable identifiers; third-party definitions may omit them. Display copy can evolve independently. Registry tests cover descriptor preservation and shadowing; client tests cover description edits in both locales, alias resolution, and exact-name priority. Composer tests cover live availability, opener replacement, unmount, and action-registration disposal. The existing Session-driven menu and command scenarios continue to own assembled browser output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 命令标识与输入框拥有的文件动作
+
+Status: implemented
+
+[English](2026-09-10-command-identities-and-composer-file-action.md) | 中文
+
+## 问题
+
+用命令的英文说明匹配客户端词典,会让标点修改影响本地化和填入的命令 token。同名覆盖也可能复制这段说明,却没有实现内置命令的行为。「文件」菜单项需要输入框实时的附件策略,包括挂载、锁定和提交状态,命令插件中的另一套检查无法确定这些状态。
+
+## 决策
+
+命令注册表在有效定义和描述符上保留可选的品牌类型 `CommandDefinitionId` 字段 `definitionId`。内置命令的提供方用包名作为稳定标识。作用域覆盖选择完整描述符,不继承被遮蔽定义的标识。该标识是发现元数据,不代表授权,也不进入命令生命周期事件。
+
+客户端命令目录通过内部的 `resolution.ts` 解析输入。精确注册名优先,中英文别名只选择会话有效目录中对应的内置定义。菜单认领使用当前语言的写法,手输认领保留原写法,提交使用解析到的注册名。`presentation.ts` 只负责分节、标题、说明和图标。解析辅助函数与小节常量不从插件入口导出。
+
+Conversation 通过注入的命令服务注册「文件」动作,并负责其本地化标题。已挂载输入框绑定文件选择器回调和一个实时可用性查询。菜单过滤与实际调用使用同一个查询,统一处理锁定、卸载、subagent 和提交状态。绑定与调用留在包内回调中,不需要跨插件的文件选择事件。组装层使用窄的结构化动作注册接口,因为命令 UI 已消费 Conversation 的输入类型,反向增加编译项目依赖会形成循环。注册测试使用命令插件的贡献项类型进行校验。
+
+本记录只接管[输入框菜单决策](../feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md)中的标识匹配、输入解析归属和文件动作归属。原记录继续负责菜单、滚动、认领保留和组合输入时序的决策。
+
+## 考虑过的替代方案
+
+**按名称和英文说明匹配。** 文案可以修改,也可以被无关定义复制。稳定标识将展示选择与文案分开,无需把本地化文本移到宿主。
+
+**只按名称匹配。** agent(智能体)作用域覆盖可能刻意在同名下提供另一种命令。除非它显式携带对应标识,否则不应继承内置展示或别名。
+
+**把文件动作留在命令插件。** 输入框负责附件接收和 DOM 生命周期。维护另一套可用性条件会把同一策略分给两个模块。
+
+**导出辅助函数以供复用。** 没有生产调用方需要把小节常量或解析辅助函数作为插件 API。测试直接导入内部模块。
+
+## 后果
+
+内置命令提供方与客户端映射必须使用一致的稳定标识,第三方定义可以省略标识。显示文案可以独立调整。注册表测试覆盖描述符保留和作用域覆盖,客户端测试覆盖两种语言下修改说明、别名解析和精确名称优先。输入框测试覆盖实时可用性、回调替换、卸载和动作注册的 dispose(资源释放)。现有会话驱动的菜单和命令场景继续负责组装后的浏览器输出。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
+2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer menu sections, localized rows, and the File row
+
+Status: implemented
+
+English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md)
+
+## Problem
+
+The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu.
+
+## Decision
+
+`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first.
+
+The six built-in Host commands get their localized title, description, glyph, and claim token from the client. Stable identity selection and input resolution follow the [command identity decision](../architecture/2026-09-10-command-identities-and-composer-file-action.md). Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
+
+A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. The effective Session catalog resolves Chinese and English aliases through the same input path in every locale.
+
+The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
+
+`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
+
+The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
+
+## Alternatives considered
+
+**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation.
+
+**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window.
+
+**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; localized product copy belongs to the client.
+
+**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
+
+**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were.
+
+**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place.
+
+**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another.
+
+## Consequences
+
+The menu and localized presentation remain client-owned, while the Host owns effective command definitions. Adding a localized first-party command requires its identity mapping, dictionary entries, icon, and menu position. Session-driven browser scenarios and owner-local ARIA expectations cover the menu. Unrelated same-name definitions keep their own copy and no first-party glyph. The command identity decision owns this distinction and the File action's lifecycle.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer 菜单分节、本地化行与「文件」行
+
+Status: implemented
+
+[English](2026-09-08-composer-menu-sections-and-localized-rows.md) | 中文
+
+## 问题
+
+composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列成 `name description`,全部小写,没有图标也不分组,旁边还有一个单独的回形针按钮用于添加文件。中文界面下各行仍是英文,因为宿主描述符只携带英文文案;只知道命令中文名的用户既搜不到它,也看不出该输入什么。Issue #3567 及其设计稿要求:按使用频次分成两个小节,每行带图标、标题左对齐、说明右对齐,英文标题首字母大写,中文标题与说明在两种语言下都能搜索并同时显示英文命令名,计划与目标的声明用中文填入,以及把「文件」入口放进菜单。
+
+## 决策
+
+`ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。
+
+六个内置宿主命令从客户端取得本地化标题、说明、图标和认领 token。稳定标识选择与输入解析遵循[命令标识决策](../architecture/2026-09-10-command-identities-and-composer-file-action.zh.md)。贡献项携带 `label()`、`description()` 和 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
+
+菜单选中填入当前语言的认领 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输 token 保留原写法作为认领,因为输入框从它持有的 token 之后读取参数。会话有效目录在所有界面语言下通过同一输入路径解析中英文别名。
+
+「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
+
+`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
+
+菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
+
+## 考虑过的替代方案
+
+**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。
+
+**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。
+
+**在宿主侧本地化说明。** 宿主没有语言设置,目录由所有客户端共享,本地化产品文案属于客户端。
+
+**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
+
+**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。
+
+**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。
+
+**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。
+
+## 后果
+
+菜单与本地化展示由客户端负责,宿主负责有效命令定义。新增本地化内置命令需要对应的标识映射、词典条目、图标和菜单位置。会话驱动的浏览器场景与归属模块的 ARIA 预期覆盖菜单。无关的同名定义保留自己的文案,不获得内置图标。命令标识决策负责这一划分和文件动作的生命周期。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
+2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d
+2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md

@@ -0,0 +1,29 @@
+# Agent Note: Turn duration labels gain an hour unit
+
+Status: implemented
+
+English | [中文](2026-09-09-turn-duration-hour-unit.zh.md)
+
+## Problem
+
+The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour.
+
+## Decision
+
+`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys.
+
+The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format.
+
+## Alternatives considered
+
+**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running.
+
+**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles.
+
+**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change.
+
+**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs.
+
+## Consequences
+
+Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 回合耗时标签增加小时单位
+
+Status: implemented
+
+[English](2026-09-09-turn-duration-hour-unit.md) | 中文
+
+## 问题
+
+Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。
+
+## 决定
+
+`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。
+
+改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。
+
+## 考虑过的替代方案
+
+**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。
+
+**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。
+
+**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。
+
+**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。
+
+## 后果
+
+长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。

+ 6 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md
+2026-09-10-approval-review-workflow-identity.md: 77164134e826acdf647db352cf73df2480055505
+2026-09-10-approval-review-workflow-identity.zh.md: 736851d6f3612080fd67ed6b72e1b8b667948d73

+ 25 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md

@@ -0,0 +1,25 @@
+# Agent Note: Identify approval review workflows by file path
+
+Status: implemented
+
+English | [中文](2026-09-10-approval-review-workflow-identity.zh.md)
+
+## Problem
+
+GitHub can populate a workflow run's `name` with its expanded `run-name`. The approval review workflow includes the pull-request number in that title, so comparing `workflow_run.name` with the static workflow name rejects valid review events before refreshing the approval status.
+
+## Decision
+
+The [approval publisher](../../../../.github/review-ownership/check-approval.mjs) identifies the review-event workflow by its exact `workflow_run.path`. It also requires a successful `pull_request_review` run, parses the pull-request number from `display_title`, validates any supplied pull-request association, and compares the current pull-request head with the reviewed head before evaluating approvals.
+
+## Alternatives considered
+
+**Accept a name prefix.** A display name does not identify the workflow file; another workflow can use the same title.
+
+**Remove the numbered run title.** The title supplies the pull-request number when GitHub returns an empty `pull_requests` array. Removing it requires a different handoff mechanism.
+
+## Consequences
+
+Run-title expansion does not prevent approval refreshes, while an unexpected workflow file still fails validation. Moving the review-event workflow requires updating the publisher's expected path.
+
+[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover a numbered run name, invalid source paths and events, unsuccessful runs, invalid titles, and superseded heads. The [approval outcome policy](2026-09-09-blocked-weighted-approvals-remain-pending.md) continues to own pending and successful status semantics.

+ 25 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 按文件路径识别审批评审工作流
+
+Status: implemented
+
+[English](2026-09-10-approval-review-workflow-identity.md) | 中文
+
+## 问题
+
+GitHub 可能用展开后的 `run-name` 填充工作流运行的 `name`。审批评审工作流在该标题中包含拉取请求编号,因此将 `workflow_run.name` 与静态工作流名称比较,会在刷新审批状态前拒绝有效的评审事件。
+
+## 决策
+
+[审批发布器](../../../../.github/review-ownership/check-approval.mjs) 按精确的 `workflow_run.path` 识别评审事件工作流。它还要求该运行由 `pull_request_review` 触发且成功完成,从 `display_title` 解析拉取请求编号,验证提供的拉取请求关联,并在计算审批结果前将拉取请求当前的头提交与已评审的头提交进行比较。
+
+## 考虑过的替代方案
+
+**接受名称前缀。** 显示名称无法识别工作流文件;另一个工作流可以使用相同的标题。
+
+**删除带编号的运行标题。** 当 GitHub 返回空的 `pull_requests` 数组时,标题提供拉取请求编号。删除它需要另一种传递机制。
+
+## 影响
+
+运行标题的展开不会阻止审批刷新,而非预期的工作流文件仍无法通过验证。移动评审事件工作流时,需要更新发布器预期的路径。
+
+[审批策略测试](../../../../.github/review-ownership/check-approval.test.mjs) 覆盖带编号的运行名称、无效的来源路径和事件、未成功的运行、无效标题以及已被替代的头提交。[审批结果策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) 继续负责待定与成功状态的语义。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
+2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2
+2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5

+ 90 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md

@@ -0,0 +1,90 @@
+# Agent Note: Whole-client test tier over an endpoint-named Remote mock
+
+Status: implemented
+
+English | [中文](2026-09-06-client-assembly-test-line.zh.md)
+
+## Problem
+
+Browser feature specs each hand-build their bench: a bare Cordis context, stand-ins for `locale`, `connection`, and `remote`, and slot declarations the real declarer would have made. Their assertions therefore describe the bench, not the product: a plugin that adds a settings section, a declarer that reloads through the Loader, or a Connection that reconnects is invisible to them, and every bench repeats the same forty lines with small drift.
+
+API client specs drive their objects through a programmable fake of the Remote surface. The fake re-implements Gateway semantics it should only exercise: follow-stream opening snapshots derived from a history list, page cuts, stream pumps with delivery promises, and an envelope layer. Each of those is a second implementation of a contract the product already owns, and it lets tests describe behavior the generated client cannot produce, such as a unary call that rejects.
+
+No focused source test boots the client the way production does — `bootClient` creating one Loader entry per manifest row, then `mountClient` — so composition faults between plugins are hidden by hand-built benches.
+
+## Decision
+
+A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the deep import `src/assembly/`, and a new test-support package `@deepseek-ai/dsh-remote-mock` answers Remote traffic by endpoint name. Both are described by their READMEs ([client-runtime](../../../../packages/test-support/client-runtime/README.md), [remote-mock](../../../../packages/test-support/remote-mock/README.md)); this note records the decisions behind them.
+
+**The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row.
+
+**The production boot path runs unchanged.** `TestClient.start(plan, mock)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` row.
+
+**`remote.<ns>` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.<ns>` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.remote.<ns>.<method>(...args)` calls the endpoint `<ns>/<method>` over the roster's own Connection with the positional args, as a stream when the mock registered a stream script for it and as a unary call otherwise. Cordis resolves `ctx.remote.<ns>` to the service `remote.<ns>`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them.
+
+**Native mocks own response configuration and call assertions.** Tests use `mock.remote.<namespace>.<method>` with `mockResolvedValue`, `mockResolvedValueOnce`, `mockReturnValueOnce`, or `mockImplementation`; the generated API supplies the signatures. Each mock instance owns its native response queue. Reusable tables register only default values or positional handlers, and each endpoint retains only its latest default. Stateful callbacks and deferred promises belong to individual tests. `ok` builds the success envelope. Streams require explicit declarations and can receive scripts over the opening args and a handle (`push`, `end`, `fail`); a declaration without a script produces a stream miss, while undeclared endpoints default to unary calls. Values are not validated. `mock.streams` controls scripted streams and exposes readiness/drain waits; `mock.log` records carrier calls (`pending`, `answered`, `failed`), scripted-stream state, first-argument `requests(endpoint?)`, and unmatched requests. `RemoteMock.create()` answers `$events` with a ready frame so the client can connect.
+
+**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions.
+
+**`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`.
+
+`mock.remote` uses native `@vitest/spy.fn` functions shared by direct callers and Connection dispatch. `MockedRemote` applies Vitest's deep mock type transformation to the entire generated namespace map; an empty map weakens only this proxy to `any`. Production `Context` and Remote declarations remain strict, with no namespace-specific type copies or compiler flags. The [proxy typing guidance](../../../../packages/test-support/remote-mock/README.md#remote-proxy) requires build-backed local type checking even when unbuilt tests pass.
+
+## Product exports added for the tier
+
+- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional.
+- `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed.
+- `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier.
+- `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them.
+- `api/gateway`: `carrierFailure` and `cancelledFailure` are exported so a stand-in for the generated client folds identically.
+
+## Alternatives considered
+
+**Running the generated `/remote` clients from built `lib/`.** Rejected: it makes source-plane specs depend on a build artifact, and the proxies need only the unary-or-stream declaration the mock already holds.
+
+**A generated static roster module with a drift gate.** Rejected after review: it is a copy of bundle data inside the test package, and every subset written against it is a hand-list that misses rows. Reading the bundles at import through the include plugin's own schema and patch application removes the copy, the generator, and the gate.
+
+**A Host compile face for the test runtime, a dynamic import of a Host module, or a vitest `globalSetup` handing rosters through `provide`/`inject`.** Rejected: a Client test runtime must not import Host code, dynamic imports hide the dependency, and a config-level channel hides the roster's source. The composition functions the launcher uses are face-neutral, so none of these is needed.
+
+**A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter.
+
+**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real.
+
+**A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime.
+
+**Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face.
+
+**Keeping CallContext and wrapping native spies in an adapter.** Rejected: it makes each test unwrap a synthetic call and retains counter/state machinery with no business-spec consumer. Positional handlers use the existing test ecosystem directly.
+
+**A separate `once` / `sequence` DSL and fallback rule stack.** Rejected: native per-instance queues already express the deferred responses and temporary failures used by consumers. Immutable table declarations with a cursor per registration would allow shared one-shot tables, but no current shared table requires them. The tier gives up newest-registration-first fallback and table-level repeat-last declarations; tests use native queue order and a persistent default instead. Response values and stateful handlers remain borrowed, not cloned.
+
+**A separate pre-materialized-module option or `staticModules`.** Rejected: the existing pending registration queue accepts the same factories before Loader startup. `staticModules` bypasses factory materialization and does not share graph prefetch/invalidation behavior; the queue removes the extra option without losing that behavior. Assembly-only helpers remain in their leaf modules rather than the recommended entry's exports.
+
+**A compiler-wide fallback flag or private augmentation package.** Rejected: declaration merging affects every file in a TypeScript Program that reaches the import; `private: true` only prevents publication. Separate test compiler graphs and additional policy checks add configuration maintenance without narrowing the fallback to its actual helper consumers. A local conditional type limits weakened inference to those consumers.
+
+**Per-namespace helpers with selected method lists and separate spy aliases.** Rejected: they repeat operation names and controls already supplied by native mocks. The generic proxy derives every method from the production namespace map, while fixtures own the returned data rather than a second implementation of domain writes or publication.
+
+## Consequences
+
+Specs boot real plugins: the whole `web` roster costs about five seconds cold and well under a second warm, and a three-row cone about twenty milliseconds per test. Assertions read product facts — the real section list, the real declarer, a Loader rebuild, a second `$events` generation on reconnect — and change when the product changes.
+
+The proxies skip the generated client's zod validation, wire-name mapping, and scoped-identity injection; mock rules read positional `args`, and the generated clients stay covered by the built-artifact e2e lanes. `remoteDefaultResponses` must gain a row when a plugin adds a boot-time call, and fails loud until it does. The two bundle names of the `web` profile are repeated once in `WEB_PROFILE_BUNDLES`, mirroring the launcher's `PROFILE_TEMPLATES.web`, and no check links the two: the client test program cannot import `@deepseek-ai/dsh-app-boot`, whose Host `Context` merges collide with the Client ones, and the test runtime takes no Host dependency even for tests. A template change therefore has to be carried to that constant by hand.
+
+The shared functions keep production and test callers on one implementation. `AppWebEntry.run()` mounts the Loader after the immediate-tier prefetch settles; application entry creation remains after prefetch, so serializing Loader setup with prefetch does not advance application activation.
+
+Native stream overrides may return their own iterable. The caller then owns consumption and cancellation; these iterables bypass scripted-stream logs and controls. Registered scripts retain managed queues and cancellation. This distinction preserves native mock behavior without adding another iterator wrapper or changing pull timing.
+
+## Deferred
+
+Product facts the tier surfaced and leaves as they are:
+
+- No `declare module` augmentation declares `Context.connection`; consumers read `ctx.get('connection') as ConnectionHandle`, and `TestClient.connection` is the typed entry the tier offers.
+- `TestClient.start` has no page-URL option, so a spec that needs the `connection` plugin to classify the page as off-loopback reconfigures the jsdom instance vitest exposes on `globalThis.jsdom`, a private detail of the jsdom environment provider.
+- `ISessions` exposes no queue observation point, so queue frames reach a `Session` through `handleControlFrame` directly rather than over the `session/control` stream.
+- A durable event pushed twice with the same seq is dropped at the tail of `RemoteJournalStream` as a replay and never reaches `SessionQueueMirror.acceptDurable`.
+- The vendored Loader rejects `create()` when a module import fails, so the import-failed branch of `assertEntriesActive` is unreachable through `create()`.
+- The session-controller client casts `ctx.remote as unknown as SessionRemotes`; in the client test program the cast is redundant, since the generated `/remote` merges are visible there.
+
+## Testing
+
+`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.

+ 90 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md

@@ -0,0 +1,90 @@
+# Agent Note: 基于端点具名 Remote mock 的整机客户端测试档
+
+Status: implemented
+
+[English](2026-09-06-client-assembly-test-line.md) | 中文
+
+## 问题
+
+浏览器功能 spec 各自手拼测试台:一个裸 Cordis context、`locale`、`connection`、`remote` 的替身,以及本该由真声明者做出的 slot 声明。它们的断言因此描述的是测试台而不是产品:一个插件新增了设置 section、一个声明者经 Loader 重载、一个 Connection 重连,对它们都不可见,而每个测试台都重复着同样的四十行并各有细小漂移。
+
+API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个假件重新实现了它本该只是调用的 Gateway 语义:从历史列表推导 follow 流的开场快照、切页、带投递 promise 的流泵,以及一层信封。每一样都是产品已有契约的第二份实现,它还让测试描述出生成客户端做不出来的行为,比如一次会 reject 的一元调用。
+
+没有聚焦源码测试按生产方式起客户端——`bootClient` 按 manifest 每行建一个 Loader entry,再 `mountClient`——插件之间的组合故障会被手工测试台遮住。
+
+## 决定
+
+整机档放在 `@deepseek-ai/dsh-client-test-runtime` 的深 import `src/assembly/` 下,新的 test-support 包 `@deepseek-ai/dsh-remote-mock` 按端点名应答 Remote 流量。两者的用法由各自 README 描述([client-runtime](../../../../packages/test-support/client-runtime/README.zh.md)、[remote-mock](../../../../packages/test-support/remote-mock/README.zh.md));本文记录它们背后的决定。
+
+**roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。
+
+**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。
+
+**`remote.<ns>` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.<ns>`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote.<ns>.<method>(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `<ns>/<method>`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.<ns>` 解析到服务 `remote.<ns>`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。
+
+**原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote.<namespace>.<method>` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。
+
+**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。
+
+**`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。
+
+`mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。
+
+## 为本档新增的产品导出
+
+- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。
+- `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。
+- `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。
+- `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。
+- `api/gateway`:导出 `carrierFailure` 与 `cancelledFailure`,让生成客户端的替身折叠得一模一样。
+
+## 考虑过的替代方案
+
+**从构建后的 `lib/` 运行生成的 `/remote` 客户端。** 否决:它让源码面的 spec 依赖构建产物,而代理只需要 mock 已持有的一元或流声明。
+
+**带漂移门禁的生成静态 roster 模块。** 评审后否决:它是测试包内的一份 bundle 数据拷贝,基于它写的每个子集都是会漏行的手列清单。用 include 插件自己的 schema 与补丁应用在 import 时读 bundle,把拷贝、生成器和门禁一起去掉。
+
+**给测试运行时加 Host 编译面、动态 import 一个 Host 模块,或用 vitest `globalSetup` 经 `provide`/`inject` 传 roster。** 否决:Client 测试运行时不得 import Host 代码,动态 import 藏起依赖,配置层通道藏起 roster 的来源。启动器用的合成函数本就面中立,这些都不需要。
+
+**自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。
+
+**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。
+
+**第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
+
+**代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。
+
+**保留 CallContext,再用适配器包装原生 spy。** 否决:每个测试都要解开合成调用对象,还保留没有业务 spec 消费者的计数/状态机制。位置参数 handler 直接使用现有测试生态。
+
+**独立的 `once` / `sequence` DSL 与回退规则栈。** 否决:按实例持有的原生队列已经能表达消费方使用的延迟响应和临时失败。不可变表声明配合每次登记的游标虽能共享一次性响应表,但当前没有共享表需要它。本档放弃最新登记优先的回退和表级末项重复声明,测试改用原生队列顺序与持续默认响应。响应值和有状态 handler 仍按引用借用,不做克隆。
+
+**独立的预加载模块选项或 `staticModules`。** 否决:现有待注册队列能在 Loader 启动前接收同样的工厂。`staticModules` 绕过工厂物化,也不共享图的预取/失效行为;队列在保留这些行为的同时省掉额外选项。仅装配内部使用的 helper 保留在叶模块,不从推荐入口再导出。
+
+**编译器级全局降级 Flag 或私有类型增补包。** 否决:声明合并会影响同一 TypeScript Program 中能够到达该导入的所有文件;`private: true` 只阻止发布。拆分测试编译图或增加策略检查会增加配置维护成本,却不能把降级限制在真正使用它的 Helper 中。局部条件类型只弱化这些消费方的类型推断。
+
+**为每个命名空间编写方法清单和独立 spy 别名的 Helper。** 否决:它们重复操作名称和原生 mock 已经提供的控制功能。通用 Proxy 从生产命名空间映射派生每个方法,fixture 拥有返回数据,而不再实现另一份领域写入或发布机制。
+
+## 后果
+
+spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远低于一秒,三行的锥每例约二十毫秒。断言读的是产品事实——真实的 section 清单、真实的声明者、一次 Loader 重建、重连时的第二代 `$events`——并随产品变化而变化。
+
+代理跳过了生成客户端的 zod 校验、wire 字段名映射与 scoped 身份注入;mock 规则读位置 `args`,生成客户端仍由构建产物 e2e 车道覆盖。插件新增启动期调用时 `remoteDefaultResponses` 必须加一行,加之前会响亮失败。`web` profile 的两个 bundle 名在 `WEB_PROFILE_BUNDLES` 里重复了一次,对应启动器的 `PROFILE_TEMPLATES.web`,且两者之间没有机检联系:客户端测试程序不能 import `@deepseek-ai/dsh-app-boot`(其 Host `Context` 合并与 Client 的冲突),而测试运行时连测试也不引入 Host 依赖。模板变更因此要靠人工带到这个常量。
+
+共享函数让生产和测试调用方使用同一份实现。`AppWebEntry.run()` 在立即层预取落定后挂载 Loader;应用 entry 仍在预取之后创建,因此让 Loader 安装与预取串行不会提前应用激活。
+
+原生流覆盖可以返回自有 iterable,此时调用方负责消费与取消,这些 iterable 不参与脚本流日志或控制。已登记的脚本仍使用受控队列与取消机制。这一区分保留原生 mock 行为,无需再添加 iterator 包装器或改变拉取时机。
+
+## 遗留事项
+
+本档暴露出来、原样保留的产品事实:
+
+- 没有任何 `declare module` 增强声明 `Context.connection`;消费方一律 `ctx.get('connection') as ConnectionHandle`,`TestClient.connection` 是本档提供的带类型入口。
+- `TestClient.start` 没有页面 URL 选项,需要 `connection` 插件把页面判为 off-loopback 的 spec 只能重配 vitest 挂在 `globalThis.jsdom` 上的 jsdom 实例,这是 jsdom 环境提供者的私有细节。
+- `ISessions` 没有 queue 观察点,queue 帧只能直接经 `handleControlFrame` 到达 `Session`,而不是走 `session/control` 流。
+- 同 seq 的 durable 事件二次推送在 `RemoteJournalStream` 尾部被当作重放丢弃,到不了 `SessionQueueMirror.acceptDurable`。
+- vendored Loader 在模块 import 失败时让 `create()` reject,因此 `assertEntriesActive` 的 import 失败分支经 `create()` 不可达。
+- session-controller 客户端把 `ctx.remote` cast 成 `SessionRemotes`;在客户端测试程序里这个 cast 是多余的,因为生成的 `/remote` 合并在那里可见。
+
+## 测试
+
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.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/testing/2026-09-06-frontend-performance-budgets.md
-2026-09-06-frontend-performance-budgets.md: 4c1a99e10c9b2d7ba7ecbd38f2249ac84e9d330b
-2026-09-06-frontend-performance-budgets.zh.md: f563c55e6440de85d71110cbfb2533c7f99218ce
+2026-09-06-frontend-performance-budgets.md: 4d69dda8a04d4e9807c18c8f1b978ca7fc87a883
+2026-09-06-frontend-performance-budgets.zh.md: c9a174df55a2c175232eb2f4d8468b054e532b9c

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md

@@ -16,7 +16,7 @@ The existing serial benchmark inventory includes two frontend owners: [active re
 
 The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted.
 
-The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups stay inside the latest Assistant step and retain visible-state waits. Diagnostics capture reply markers and focus immediately after the first-visible wait, plus browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak.
+The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups sample visible text inside the latest Assistant step on animation frames, avoiding selector retry backoff. Hidden text and markers in older steps cannot satisfy the observer. The first observation captures reply markers and focus in the browser; diagnostics are retrieved after typing to avoid an additional pre-input round trip. Diagnostics also capture browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak.
 
 Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md

@@ -16,7 +16,7 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式
 
 浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。
 
-续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。诊断在首段可见等待后立即记录回复标记和焦点,并记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。
+续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找在动画帧上采样最新 Assistant step 内的可见文本,避免选择器重试退避。隐藏文本和较早步骤中的标记均不能满足观察条件。首次观察在浏览器内记录回复标记与焦点;诊断在键入后取回,避免增加输入前的通信往返。诊断还记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。
 
 重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。
 

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.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/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 2249df5467189975aca2d73ec56c6cf82ec7b62f
-2026-09-08-ci-readiness-and-completion.zh.md: ccf8e7c16903b66e39bc48e99460e5c5179ab51b
+2026-09-08-ci-readiness-and-completion.md: c72048ef90b987e7d27992b1754736b2ce895093
+2026-09-08-ci-readiness-and-completion.zh.md: 1188d66249b3092416435da2e8496a657ad00677

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -14,6 +14,8 @@ The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/acti
 
 A [worker-runtime coverage failure](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932) exhausts the slow-binding fixture's one-second compute allowance. Concurrent native Windows reproductions exceed that allowance before calling the binding. Worker initialization contributes measured active time; the delayed binding contributes idle time.
 
+The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193) reports an SDK subprocess exit beyond a fixture's 200 ms confirmation window and an Inspector Worker startup beyond its ten-second default. Neither the protocol-error routing case nor the Cordis tree projection case measures those latency guarantees.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -28,6 +30,8 @@ The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) o
 
 The [worker-runtime binding test](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) allows five seconds of compute for source-worker initialization and delays the binding for 6.5 seconds. Charging that idle delay would still exceed the entire compute allowance. The case retains its 15-second test limit and 30-second wall ceiling, registers Context and reply-timer cleanup, and leaves the hot-loop, decoy-dispatch, wall-ceiling, and abort controls at their existing limits. Production budgets remain unchanged.
 
+The [SDK subagent protocol-error test](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts) uses the provider's normal shutdown and exit grace periods and registers disposal before its assertions. The [Inspector tree tests](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts) pass the active test budget to Worker startup and register cleanup while startup is still pending. A cancelled test cannot receive a late-ready handle; cleanup awaits initialization and closes a successfully started Worker. Failed initialization already terminates the Worker before rejecting. A controlled late-start test verifies cancellation and closure through the real Worker's HTTP endpoint. Production defaults remain unchanged.
+
 ## Alternatives considered
 
 **Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget.

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -14,6 +14,8 @@ Status: implemented
 
 一次 [worker runtime coverage 失败](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932)耗尽了慢 binding 夹具的一秒计算额度。原生 Windows 并发复现在调用 binding 前已超过该额度。Worker 初始化会累计所测的活跃时间;延迟的 binding 累计空闲时间。
 
+[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193)报告了 SDK 子进程退出超过测试设置的 200 毫秒确认期限,以及 Inspector Worker 启动超过十秒默认期限。协议错误转发用例和 Cordis 树投影用例都不衡量这些延迟保证。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -28,6 +30,8 @@ Status: implemented
 
 [Worker runtime binding 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)为源码 worker 初始化保留五秒计算额度,并将 binding 延迟设为 6.5 秒。若将该空闲延迟计费,仍会超过整个计算额度。用例保留 15 秒测试期限与 30 秒墙钟上限,登记 Context 和回复定时器的清理,并保持热循环、诱饵 dispatch、墙钟上限及取消控制用例的原有限制。生产预算不变。
 
+[SDK 子 Agent 协议错误测试](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts)使用提供方正常的关闭和退出等待时间,并在断言前登记清理。[Inspector 树测试](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts)将当前测试预算传给 Worker 启动,并在启动尚未完成时登记清理。取消后的测试不会收到随后才就绪的实例;清理等待初始化完成,并关闭成功启动的 Worker。初始化失败时,启动操作会在拒绝前终止 Worker。受控的延迟启动测试通过真实 Worker 的 HTTP 端点验证取消和关闭。生产默认值不变。
+
 ## 考虑过的替代方案
 
 **增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。

+ 6 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
+2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7
+2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571

+ 95 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md

@@ -0,0 +1,95 @@
+# Agent Note: Uninstall Desktop while preserving the Harness home
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
+
+## Problem
+
+Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage.
+
+The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory.
+
+## Proposal
+
+Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup.
+
+Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup.
+
+User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history.
+
+### 1. Establish ownership and actual paths
+
+Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths.
+
+| Candidate | Windows | macOS | Required treatment |
+|---|---|---|---|
+| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. |
+| Installer/update cache | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. |
+| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `<appId>.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. |
+| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. |
+| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. |
+| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. |
+
+Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts.
+
+### 2. Implement shared preservation and cleanup rules
+
+Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location.
+
+Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview.
+
+Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup.
+
+Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable.
+
+### 3. Windows implementation
+
+Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup.
+
+Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`.
+
+Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result.
+
+Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged.
+
+### 4. macOS implementation
+
+Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied.
+
+Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification.
+
+Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned.
+
+### 5. Verification and documentation
+
+Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests.
+
+Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed.
+
+Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests.
+
+## Alternatives considered
+
+**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome.
+
+**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here.
+
+**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation.
+
+**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations.
+
+## Acceptance criteria
+
+- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains.
+- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact.
+- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data.
+- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash.
+- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result.
+- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion.
+
+## Risks
+
+Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention.
+
+OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note.

+ 95 - 0
.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md

@@ -0,0 +1,95 @@
+# Agent Note: 卸载 Desktop 并保留 Harness 主目录
+
+Status: proposed
+
+[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
+
+## 问题
+
+用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。
+
+[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。
+
+## 提案
+
+提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。
+
+Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。
+
+用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。
+
+### 1. 确定归属和实际路径
+
+使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。
+
+| 候选项 | Windows | macOS | 处理要求 |
+|---|---|---|---|
+| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 |
+| 安装器及更新缓存 | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 |
+| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `<appId>.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 |
+| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 |
+| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 |
+| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 |
+
+建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。
+
+### 2. 实现共用的保留与清理规则
+
+按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。
+
+拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。
+
+阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。
+
+不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。
+
+### 3. Windows 实现
+
+保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。
+
+正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。
+
+按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。
+
+由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。
+
+### 4. macOS 实现
+
+在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。
+
+使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。
+
+把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。
+
+### 5. 验证和文档
+
+增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。
+
+在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。
+
+为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。
+
+## 考虑过的替代方案
+
+**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。
+
+**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。
+
+**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。
+
+**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。
+
+## 验收标准
+
+- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。
+- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。
+- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。
+- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。
+- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。
+- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。
+
+## 风险
+
+删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。
+
+操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。

+ 1 - 0
.agents/skills/dsh-pre-push-checks/SKILL.md

@@ -31,6 +31,7 @@ There is no universal local baseline beyond the hooks. Every behavior change nee
 When the outgoing change adds or changes a resource-owning or asynchronous test, fixture, helper, or CI execution path, use [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) first to decide whether restoration, negative-control, quiescent-teardown, or concurrent-process evidence applies. This skill still selects the commands and avoids repeating evidence that already passed.
 
 - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it.
+- **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures.
 - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it.
 - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output.
 - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.<ordinal>[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane.

+ 1 - 1
.github/review-ownership/README.md

@@ -27,7 +27,7 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea
 
 ## Security
 
-The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
+The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 

+ 4 - 2
.github/review-ownership/check-approval.mjs

@@ -208,13 +208,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, write
 }
 
 /**
- * Resolve the reviewed pull request from a completed review-event workflow run.
+ * Resolve the reviewed pull request from a completed run of the review-event workflow file.
  * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
  * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
  */
 export async function approvalEventFromWorkflowRun({ event, api }) {
   const repository = repositoryFromEvent(event)
-  if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event'
+  // GitHub can expand run-name into name; the file path identifies the source workflow.
+  if (!isRecord(event.workflow_run)
+    || event.workflow_run.path !== '.github/workflows/weighted-approval-review-event.yml'
     || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') {
     throw new Error('event has no successful weighted approval review workflow run')
   }

+ 17 - 1
.github/review-ownership/check-approval.test.mjs

@@ -78,7 +78,8 @@ test('resolves a review workflow run to the current pull request and rejects sta
   const workflowRunEvent = {
     repository: { full_name: 'deepseek-harness/deepseek-harness' },
     workflow_run: {
-      name: 'weighted-approval-review-event',
+      name: 'weighted-approval-review-event:42',
+      path: '.github/workflows/weighted-approval-review-event.yml',
       event: 'pull_request_review',
       conclusion: 'success',
       head_sha: HEAD_SHA,
@@ -95,6 +96,21 @@ test('resolves a review workflow run to the current pull request and rejects sta
   })
   assert.equal(current.pull_request.number, 42)
 
+  for (const invalidRun of [
+    { path: '.github/workflows/other.yml' },
+    { path: undefined },
+    { event: 'push' },
+    { conclusion: 'failure' },
+  ]) {
+    await assert.rejects(approvalEventFromWorkflowRun({
+      event: {
+        ...workflowRunEvent,
+        workflow_run: { ...workflowRunEvent.workflow_run, ...invalidRun },
+      },
+      api: async () => { throw new Error('invalid source must not call GitHub') },
+    }), /successful weighted approval review workflow run/u)
+  }
+
   assert.equal(await approvalEventFromWorkflowRun({
     event: workflowRunEvent,
     api: async () => ({

+ 2 - 1
THIRD_PARTY_NOTICES.md

@@ -114,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 
 pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
 
+- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
 - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
 - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
 
@@ -169,6 +170,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
 | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
+| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT |
 | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
 | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |
@@ -191,7 +193,6 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
 | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
-| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
 | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |

+ 1 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -874,6 +874,7 @@ describe('authoring a preset on the shipped composition', () => {
  */
 describe('the default preset as a user setting', () => {
   it('composes an unnamed session from the stored default, not the composed one', async () => {
+    expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
     expect(ctx.agentPresets.defaultId).toBe('standard')
 
     await ctx.settings.update(SETTINGS_NAMESPACE, { default: 'minimal' })

+ 19 - 15
apps/desktop-host/src/index.ts

@@ -149,12 +149,12 @@ function isProjectPath(projectDir: string, target: string): boolean {
   return path === root || path.startsWith(root + sep)
 }
 
-function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
-  const dshRoot = dirname(packageManifestPath(projectDir, '@deepseek-ai/dsh'))
+function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
+  const dshRoot = dirname(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
   const profile = loadProfileDirectory('dsh desktop', projectDir, join(dshRoot, 'package.json'))
   for (const layer of profile.layers) {
-    if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir)) {
-      throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the desktop profile`)
+    if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) {
+      throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`)
     }
   }
   const layers = [
@@ -176,14 +176,14 @@ function desktopPatches(projectDir: string, allowLinkedPackages: boolean): Patch
   return layers.flat()
 }
 
-function dshVersion(projectDir: string): string {
-  const manifest = readManifest(packageManifestPath(projectDir, '@deepseek-ai/dsh'))
+function dshVersion(runtimeDir: string): string {
+  const manifest = readManifest(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
   if (typeof manifest.version !== 'string') throw new Error('dsh desktop: installed dsh manifest has no version')
   return manifest.version
 }
 
-function assetHandler(ctx: Context, projectDir: string): ConnectionFetchHandler {
-  const require = createRequire(join(projectDir, 'package.json'))
+function assetHandler(ctx: Context, runtimeDir: string): ConnectionFetchHandler {
+  const require = createRequire(join(runtimeDir, 'package.json'))
   const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
   const distRoot = realpathSync(dirname(distIndex))
   const renderIndex = async (): Promise<Response> => {
@@ -270,12 +270,14 @@ interface NodeRequestInit extends RequestInit {
 
 /**
  * Boot one installed desktop npm project.
+ * @param runtimeDir - immutable dsh packages supplied by the Electron application.
  * @param projectDir - active or staged Electron-owned desktop profile.
  * @param writeResponse - serialized response-pipe writer that applies byte backpressure.
  * @param options - development-only allowance for workspace-linked bundle packages.
  * @returns controller after every Host and client-manifest row is active.
  */
 export async function runDesktopHost(
+  runtimeDir: string,
   projectDir: string,
   writeResponse: (frame: Buffer) => Promise<void>,
   options: { allowLinkedPackages?: boolean } = {},
@@ -287,6 +289,7 @@ export async function runDesktopHost(
   const environment = loadLayeredEnv('dsh desktop')
   let current: Context | undefined
   const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches(
+    resolve(runtimeDir),
     absoluteProject,
     options.allowLinkedPackages === true,
   )), (hostCtx) => {
@@ -303,7 +306,7 @@ export async function runDesktopHost(
     throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules')
   }
   const api = connection.createSharedFetchHandler('/api')
-  const assets = assetHandler(ctx, absoluteProject)
+  const assets = assetHandler(ctx, resolve(runtimeDir))
   const streams = remoteStreamHandler(ctx)
   const requests = new Map<number, AbortController>()
   let disposing: Promise<void> | undefined
@@ -319,7 +322,7 @@ export async function runDesktopHost(
   }
 
   return {
-    dshVersion: dshVersion(absoluteProject),
+    dshVersion: dshVersion(resolve(runtimeDir)),
     cancel(streamId) {
       requests.get(streamId)?.abort()
     },
@@ -374,11 +377,12 @@ export async function runDesktopHost(
 }
 
 async function main(): Promise<void> {
-  const projectDir = process.argv[2]
-  if (projectDir === undefined || process.send === undefined) {
-    throw new Error('dsh desktop: expected project directory, byte pipes, and a Node IPC channel')
+  const runtimeDir = process.argv[2]
+  const projectDir = process.argv[3]
+  if (runtimeDir === undefined || projectDir === undefined || process.send === undefined) {
+    throw new Error('dsh desktop: expected runtime and profile directories, byte pipes, and a Node IPC channel')
   }
-  const option = process.argv[3]
+  const option = process.argv[4]
   if (option !== undefined && option !== '--allow-linked-profile') {
     throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`)
   }
@@ -403,7 +407,7 @@ async function main(): Promise<void> {
       if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error
     }
   }
-  const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
+  const controller = await runDesktopHost(runtimeDir, projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
   send({
     type: 'ready',
     protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,

+ 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: cf350a9d5e9577f5cf52c4371b32f16e278133f9
-README.zh.md: aa870c2a74f3b74d3f6de921c4bc5ef04fbfad3f
+README.md: 62d592307161f202f156d66d91f5a4663c3facc1
+README.zh.md: ace1497876d8b60612b8b5adf1d3ee86f1beeaa4

Fișier diff suprimat deoarece este prea mare
+ 52 - 32
apps/desktop/README.md


Fișier diff suprimat deoarece este prea mare
+ 52 - 32
apps/desktop/README.zh.md


+ 7 - 2
apps/desktop/electron-builder.config.d.mts

@@ -6,19 +6,24 @@ export interface DesktopElectronBuilderConfig {
   }
   readonly extraResources: readonly [
     { readonly from: string, readonly to: 'runtime' },
-    { readonly from: string, readonly to: 'seed' },
+    { readonly from: string, readonly to: 'dsh' },
+    { readonly from: string, readonly to: 'dsh/node_modules' },
   ]
   readonly mac: {
     readonly identity: string | undefined
     readonly forceCodeSigning: boolean
     readonly notarize: boolean
+    readonly signIgnore: readonly string[]
   }
   readonly dmg: {
     readonly sign: boolean
     readonly writeUpdateInfo: boolean
   }
+  readonly nsis: {
+    readonly include: string
+  }
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
-  readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }]
+  readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }
 
 /**

+ 29 - 9
apps/desktop/electron-builder.config.mjs

@@ -1,3 +1,5 @@
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import {
   resolveDesktopAppId,
   resolveMacOSNotarizationEnvironment,
@@ -10,7 +12,7 @@ import {
   installWindowsNsisBootstrapSigner,
 } from './scripts/windows-sign.mjs'
 import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
-import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs'
+import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs'
 
 /**
  * Create electron-builder configuration from one release environment.
@@ -28,11 +30,16 @@ export function createElectronBuilderConfig(
   const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
   const resolvedPlatform = targetPlatform ?? hostPlatform
   const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
+  if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) {
+    throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1')
+  }
+  const unsigned = env.DSH_DESKTOP_UNSIGNED === '1'
+  if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows')
   const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
   const packagesWindows = targetPlatform === 'win32'
   const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
   if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
-  const windowsSigner = packagesWindows
+  const windowsSigner = packagesWindows && !unsigned
     ? createWindowsTokenSigner({
         certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
         signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
@@ -43,13 +50,13 @@ export function createElectronBuilderConfig(
   if (windowsSigner !== undefined) {
     installWindowsNsisBootstrapSigner({ sign: windowsSigner })
   }
-  const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
-  const buildPaths = desktopTargetBuildPaths(update.target)
+  const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
+  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
   return {
     appId,
     productName: 'DeepSeek Harness',
     artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
-    directories: { output: buildPaths.artifacts },
+    directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
     asar: true,
     files: [
       'lib/*.js',
@@ -59,13 +66,17 @@ export function createElectronBuilderConfig(
     ],
     extraResources: [
       { from: buildPaths.runtime, to: 'runtime' },
-      { from: buildPaths.seed, to: 'seed' },
+      { from: buildPaths.dsh, to: 'dsh' },
+      // electron-builder excludes a source directory's root node_modules.
+      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' },
     ],
     mac: {
       category: 'public.app-category.developer-tools',
       identity: macOSSigning?.signingIdentity,
       forceCodeSigning: true,
       hardenedRuntime: true,
+      // Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
+      signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'],
       notarize: true,
       target: ['dmg', 'zip'],
     },
@@ -73,8 +84,16 @@ export function createElectronBuilderConfig(
       sign: true,
       writeUpdateInfo: false,
     },
-    afterSign: context => {
+    afterPack: async context => {
+      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
+      await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
+        context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
+    },
+    afterSign: async context => {
       if (context.electronPlatformName !== 'darwin') return
+      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
+      await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
+        context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch })
       verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
     },
     artifactBuildCompleted: artifact => {
@@ -86,7 +105,7 @@ export function createElectronBuilderConfig(
       )
     },
     win: {
-      forceCodeSigning: true,
+      forceCodeSigning: !unsigned,
       signtoolOptions: {
         sign: windowsSigner,
         signingHashAlgorithms: ['sha256'],
@@ -98,11 +117,12 @@ export function createElectronBuilderConfig(
       target: ['AppImage'],
     },
     nsis: {
+      include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)),
       oneClick: false,
       allowToChangeInstallationDirectory: true,
       differentialPackage: true,
     },
-    publish: [{ provider: 'generic', url: update.publicUrl }],
+    publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }],
   }
 }
 

+ 3 - 3
apps/desktop/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-desktop",
-  "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime",
+  "description": "Electron desktop shell for a bundled dsh runtime and external plugins",
   "version": "0.1.5-rc.1",
   "private": true,
   "license": "MIT",
@@ -12,7 +12,7 @@
     "start": "tsx scripts/dev.ts --skip-build",
     "prepare:runtime": "tsx scripts/prepare-runtime.ts",
     "prepare:packages": "tsx scripts/prepare-package-set.ts",
-    "prepare:seed": "tsx scripts/prepare-seed.ts",
+    "prepare:dsh": "tsx scripts/prepare-dsh.ts",
     "prepare:package": "tsx scripts/package-target.ts --prepare-only",
     "verify:mac-signature": "node scripts/verify-macos-signature.mjs",
     "package": "tsx scripts/package-target.ts",
@@ -22,6 +22,7 @@
     "package:mac:x64": "tsx scripts/package-target.ts mac-x64",
     "package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir",
     "package:win:x64": "tsx scripts/package-target.ts win-x64",
+    "package:win:x64:unsigned": "tsx scripts/package-target.ts win-x64 --unsigned",
     "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
     "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
     "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",
@@ -43,7 +44,6 @@
     "electron-builder": "^26.15.3",
     "extract-zip": "^2.0.1",
     "js-yaml": "^4.2.0",
-    "msgpackr": "2.0.4",
     "pnpm": "11.7.0",
     "tar": "^7.5.0",
     "typescript": "^6.0.3"

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

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

+ 17 - 2
apps/desktop/renderer/plugin-manager.js

@@ -14,6 +14,10 @@ async function main() {
   document.querySelector('#installed-heading').textContent = messages.installed
   document.querySelector('#empty').textContent = messages.noPlugins
 
+  document.querySelector('#recovery-description').textContent = messages.recoveryDescription
+  document.querySelector('#retry').textContent = messages.retry
+  document.querySelector('#disable-all').textContent = messages.disableAll
+
   const list = document.querySelector('#plugins')
   const empty = document.querySelector('#empty')
   const status = document.querySelector('#status')
@@ -27,13 +31,16 @@ async function main() {
   }
 
   async function render() {
+    const backend = await api.backend.status()
+    document.querySelector('#recovery').hidden = backend.phase !== 'error'
+    document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : ''
     const plugins = await api.plugins.list()
     list.replaceChildren(...plugins.map(plugin => {
       const item = document.createElement('li')
       const identity = document.createElement('span')
       const version = document.createElement('span')
       version.className = 'package-version'
-      version.textContent = plugin.version
+      version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}`
       identity.append(document.createTextNode(plugin.name), version)
       const remove = document.createElement('button')
       remove.type = 'button'
@@ -52,7 +59,13 @@ async function main() {
       })
       const actions = document.createElement('span')
       actions.className = 'package-actions'
-      actions.append(update, remove)
+      const toggle = document.createElement('button')
+      toggle.type = 'button'
+      toggle.textContent = plugin.enabled ? messages.disable : messages.enable
+      toggle.addEventListener('click', () => void run(
+        () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation,
+      ))
+      actions.append(toggle, update, remove)
       item.append(identity, actions)
       return item
     }))
@@ -93,6 +106,8 @@ async function main() {
       input.value = ''
     }, message('installing', { spec }))
   })
+  document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
+  document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
 
   await load(messages.loadingPlugins, '')

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

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

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

@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
+    <title id="page-title"></title>
+    <link rel="stylesheet" href="startup.css">
+  </head>
+  <body>
+    <main aria-busy="true">
+      <div id="spinner" aria-hidden="true"></div>
+      <h1 id="title" role="status" aria-live="polite"></h1>
+      <p id="description"></p>
+      <p id="reset-advice" hidden></p>
+      <p id="reinstall-advice" hidden></p>
+      <pre id="error" role="alert" hidden></pre>
+      <div id="actions" hidden>
+        <button id="restart" type="button"></button>
+        <button id="disable-plugins" type="button"></button>
+        <button id="reset-configuration" type="button"></button>
+      </div>
+    </main>
+    <script src="startup.js"></script>
+  </body>
+</html>

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

@@ -0,0 +1,45 @@
+const api = window.dshDesktop
+
+async function main() {
+  const { id, messages } = await api.locale()
+  document.documentElement.lang = id
+  document.querySelector('#page-title').textContent = messages.startupLoading
+  document.querySelector('#restart').textContent = messages.restartApplication
+  document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins
+  document.querySelector('#reset-configuration').textContent = messages.resetConfiguration
+  document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice
+  document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice
+  function render(state) {
+    const failed = state.phase === 'error'
+    document.querySelector('main').setAttribute('aria-busy', String(!failed))
+    document.querySelector('#spinner').hidden = failed
+    document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading
+    document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription
+    document.querySelector('#error').hidden = !failed
+    document.querySelector('#error').textContent = failed ? state.message : ''
+    document.querySelector('#actions').hidden = !failed
+    for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
+    for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) {
+      document.querySelector(selector).hidden = !failed || !state.profileRecovery
+    }
+    document.querySelector('#reinstall-advice').hidden = !failed
+  }
+  let changed = false
+  const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) })
+  window.addEventListener('pagehide', unsubscribe, { once: true })
+  const initial = await api.backend.status()
+  if (!changed) render(initial)
+  async function recover(operation) {
+    render({ phase: 'starting' })
+    try { await operation() }
+    catch (error) {
+      const current = await api.backend.status().catch(() => undefined)
+      render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) })
+    }
+  }
+  document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) })
+  document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) })
+  document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) })
+}
+
+void main()

+ 2 - 2
apps/desktop/scripts/desktop-build-paths.d.mts

@@ -6,8 +6,8 @@ export interface DesktopTargetBuildPaths {
   readonly artifacts: string
   readonly runtime: string
   readonly packageSet: string
-  readonly seed: string
-  readonly seedPnpm: string
+  readonly dsh: string
+  readonly dshPnpm: string
   readonly nodeExtract: string
   readonly packedDsh: string
   readonly packedVendor: string

+ 3 - 3
apps/desktop/scripts/desktop-build-paths.mjs

@@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget(
 /**
  * Return the mutable preparation and artifact directories owned by one release target.
  * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
- * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
+ * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
  */
 export function desktopTargetBuildPaths(target) {
   if (!SUPPORTED_TARGETS.has(target)) {
@@ -44,8 +44,8 @@ export function desktopTargetBuildPaths(target) {
     artifacts: join(root, 'artifacts'),
     runtime: join(root, 'runtime'),
     packageSet: join(root, 'package-set'),
-    seed: join(root, 'seed'),
-    seedPnpm: join(root, 'seed-pnpm'),
+    dsh: join(root, 'dsh'),
+    dshPnpm: join(root, 'dsh-pnpm'),
     nodeExtract: join(root, 'node-extract'),
     packedDsh: join(packed, 'dsh'),
     packedVendor: join(packed, 'vendor'),

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

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

+ 17 - 0
apps/desktop/scripts/installer.nsh

@@ -0,0 +1,17 @@
+!include "LogicLib.nsh"
+
+!macro customInstall
+  Push $0
+  StrCpy $0 0
+  ${If} ${Errors}
+    StrCpy $0 1
+  ${EndIf}
+  ; Finish can launch the app while NSIS removes its remaining plugin directory.
+  RMDir /r "$PLUGINSDIR\7z-out"
+  ${If} $0 == 1
+    SetErrors
+  ${Else}
+    ClearErrors
+  ${EndIf}
+  Pop $0
+!macroend

+ 43 - 0
apps/desktop/scripts/macos-runtime.ts

@@ -0,0 +1,43 @@
+/** Sign final native runtime files before the enclosing Desktop application is signed. */
+
+import { createHash } from 'node:crypto'
+import { closeSync, openSync, readSync } from 'node:fs'
+import { join } from 'node:path'
+import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
+import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
+import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
+
+const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca'])
+
+function isMachO(path: string): boolean {
+  const descriptor = openSync(path, 'r')
+  try {
+    const header = Buffer.alloc(4)
+    return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex'))
+  } finally { closeSync(descriptor) }
+}
+
+/**
+ * Sign and verify every materialized Mach-O file, awaiting all signers on failure.
+ * @param root - Self-contained production runtime without symlinks.
+ * @param appId - Release application identifier.
+ * @param expected - Required signing identity.
+ * @returns Number of signed native files.
+ */
+export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise<number> {
+  const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path)))
+  let next = 0
+  const workers = Array.from({ length: Math.min(4, files.length) }, async () => {
+    for (;;) {
+      const path = files[next++]
+      if (path === undefined) return
+      const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
+      await signMacOSRuntimeCode(join(root, path), identifier, expected)
+      verifyMacOSRuntimeCode(join(root, path), expected)
+    }
+  })
+  const results = await Promise.allSettled(workers)
+  const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
+  if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed')
+  return files.length
+}

+ 0 - 413
apps/desktop/scripts/macos-seed-store.ts

@@ -1,413 +0,0 @@
-/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */
-
-import { createHash } from 'node:crypto'
-import {
-  chmodSync,
-  closeSync,
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  mkdtempSync,
-  openSync,
-  readFileSync,
-  readSync,
-  readdirSync,
-  rmSync,
-  unlinkSync,
-  writeFileSync,
-} from 'node:fs'
-import { availableParallelism, tmpdir } from 'node:os'
-import { basename, dirname, join, relative, sep } from 'node:path'
-import { DatabaseSync } from 'node:sqlite'
-import { Packr } from 'msgpackr'
-import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
-import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs'
-
-const MACH_O_MAGICS = new Set([
-  'cafebabe',
-  'cafebabf',
-  'cefaedfe',
-  'cffaedfe',
-  'feedface',
-  'feedfacf',
-  'bebafeca',
-  'bfbafeca',
-])
-const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u
-const MAX_CONCURRENT_CODE_SIGNERS = 4
-const packr = new Packr({ moreTypes: true, useRecords: true })
-
-interface PnpmStoreFileRecord {
-  checkedAt: number
-  digest: string
-  mode: number
-  size: number
-}
-
-interface PnpmSideEffectsRecord {
-  readonly added?: Map<string, PnpmStoreFileRecord>
-}
-
-interface PnpmPackageIndexRecord {
-  readonly algo?: string
-  readonly files?: Map<string, PnpmStoreFileRecord>
-  readonly sideEffects?: Map<string, PnpmSideEffectsRecord>
-}
-
-interface DecodedIndexRow {
-  readonly key: string
-  readonly value: PnpmPackageIndexRecord
-  changed: boolean
-}
-
-interface CasFile {
-  readonly path: string
-  readonly digest: string
-  readonly executable: boolean
-}
-
-interface FileReference {
-  readonly row: DecodedIndexRow
-  readonly record: PnpmStoreFileRecord
-}
-
-interface SigningWork {
-  readonly file: CasFile
-  readonly references: readonly FileReference[]
-  readonly temporaryPath: string
-}
-
-/** Summary of native code rewritten in one pnpm store. */
-export interface MacOSSeedStoreSigningResult {
-  readonly signedFiles: number
-  readonly prunedOrphans: number
-  readonly updatedIndexRows: number
-}
-
-/** A signer used to make one writable Mach-O copy release-valid. */
-export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise<void>
-
-/** A verifier used to check one Mach-O file after packaging transport. */
-export type MacOSSeedCodeVerifier = (path: string) => void
-
-/** Optional execution controls for seed-store code signing. */
-export interface MacOSSeedStoreSigningOptions {
-  readonly signer?: MacOSSeedCodeSigner
-  readonly concurrency?: number
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null
-}
-
-function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord {
-  if (!isRecord(value)) return false
-  return typeof value.checkedAt === 'number'
-    && typeof value.digest === 'string'
-    && /^[0-9a-f]{128}$/u.test(value.digest)
-    && Number.isSafeInteger(value.mode)
-    && Number.isSafeInteger(value.size)
-}
-
-function packageFileMaps(value: unknown, key: string): readonly Map<string, PnpmStoreFileRecord>[] {
-  if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`)
-  const record = value as PnpmPackageIndexRecord
-  if (record.algo !== undefined && record.algo !== 'sha512') {
-    throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`)
-  }
-  const maps: Map<string, PnpmStoreFileRecord>[] = []
-  if (record.files !== undefined) {
-    if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`)
-    maps.push(record.files)
-  }
-  if (record.sideEffects !== undefined) {
-    if (!(record.sideEffects instanceof Map)) {
-      throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`)
-    }
-    for (const effect of record.sideEffects.values()) {
-      if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`)
-      if (effect.added === undefined) continue
-      if (!(effect.added instanceof Map)) {
-        throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`)
-      }
-      maps.push(effect.added)
-    }
-  }
-  for (const files of maps) {
-    for (const file of files.values()) {
-      if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`)
-    }
-  }
-  return maps
-}
-
-function isExecutableMode(mode: number): boolean {
-  return (mode & 0o111) !== 0
-}
-
-function referenceKey(digest: string, executable: boolean): string {
-  return `${digest}:${executable ? 'exec' : 'nonexec'}`
-}
-
-function isMachO(path: string): boolean {
-  const descriptor = openSync(path, 'r')
-  try {
-    const header = Buffer.alloc(4)
-    return readSync(descriptor, header, 0, header.length, 0) === header.length
-      && MACH_O_MAGICS.has(header.toString('hex'))
-  } finally {
-    closeSync(descriptor)
-  }
-}
-
-function visitFiles(root: string): readonly string[] {
-  const files: string[] = []
-  const visit = (directory: string): void => {
-    for (const entry of readdirSync(directory, { withFileTypes: true })) {
-      const path = join(directory, entry.name)
-      if (entry.isSymbolicLink()) {
-        throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`)
-      }
-      if (entry.isDirectory()) visit(path)
-      else if (entry.isFile()) files.push(path)
-      else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`)
-    }
-  }
-  visit(root)
-  return files.sort((left, right) => left.localeCompare(right))
-}
-
-function versionRoots(storeRoot: string): readonly string[] {
-  return readdirSync(storeRoot, { withFileTypes: true })
-    .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name))
-    .map(entry => join(storeRoot, entry.name))
-    .filter(root => existsSync(join(root, 'files')))
-    .sort((left, right) => left.localeCompare(right))
-}
-
-function casFiles(versionRoot: string): readonly CasFile[] {
-  const filesRoot = join(versionRoot, 'files')
-  const result: CasFile[] = []
-  for (const path of visitFiles(filesRoot)) {
-    if (!isMachO(path)) continue
-    const normalized = relative(filesRoot, path).split(sep).join('/')
-    const match = CAS_PATH_PATTERN.exec(normalized)
-    if (match === null) {
-      throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`)
-    }
-    result.push({
-      path,
-      digest: `${match[1]}${match[2]}`,
-      executable: match[3] !== undefined,
-    })
-  }
-  return result
-}
-
-function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] {
-  const rows: DecodedIndexRow[] = []
-  for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{
-    key: string
-    data: Uint8Array
-  }>) {
-    rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false })
-  }
-  return rows
-}
-
-function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap<string, readonly FileReference[]> {
-  const references = new Map<string, FileReference[]>()
-  for (const row of rows) {
-    for (const files of packageFileMaps(row.value, row.key)) {
-      for (const record of files.values()) {
-        const key = referenceKey(record.digest, isExecutableMode(record.mode))
-        const values = references.get(key) ?? []
-        values.push({ row, record })
-        references.set(key, values)
-      }
-    }
-  }
-  return references
-}
-
-function writeCasFile(path: string, body: Buffer, mode: number): void {
-  mkdirSync(dirname(path), { recursive: true })
-  try {
-    writeFileSync(path, body, { flag: 'wx', mode })
-  } catch (error) {
-    if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error
-  }
-  chmodSync(path, mode)
-}
-
-function signedCasPath(versionRoot: string, digest: string, executable: boolean): string {
-  return join(
-    versionRoot,
-    'files',
-    digest.slice(0, 2),
-    `${digest.slice(2)}${executable ? '-exec' : ''}`,
-  )
-}
-
-async function runConcurrent<T>(
-  values: readonly T[],
-  concurrency: number,
-  run: (value: T) => Promise<void>,
-): Promise<void> {
-  let next = 0
-  const failure: { error?: unknown; failed: boolean } = { failed: false }
-  const worker = async (): Promise<void> => {
-    while (!failure.failed) {
-      const index = next
-      if (index >= values.length) return
-      next += 1
-      try {
-        await run(values[index] as T)
-      } catch (error) {
-        if (!failure.failed) {
-          failure.failed = true
-          failure.error = error
-        }
-      }
-    }
-  }
-  const workers = Array.from(
-    { length: Math.min(concurrency, values.length) },
-    async () => worker(),
-  )
-  await Promise.all(workers)
-  if (failure.failed) throw failure.error
-}
-
-async function rewriteVersionStore(
-  versionRoot: string,
-  appId: string,
-  signer: MacOSSeedCodeSigner,
-  concurrency: number,
-): Promise<MacOSSeedStoreSigningResult> {
-  const databasePath = join(versionRoot, 'index.db')
-  if (!existsSync(databasePath)) {
-    throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`)
-  }
-  const database = new DatabaseSync(databasePath)
-  const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-'))
-  const obsoleteFiles = new Set<string>()
-  let prunedOrphans = 0
-  let rows: readonly DecodedIndexRow[] = []
-  try {
-    rows = readIndexRows(database)
-    const references = fileReferences(rows)
-    const signingWork: SigningWork[] = []
-    for (const file of casFiles(versionRoot)) {
-      const body = readFileSync(file.path)
-      const actualDigest = createHash('sha512').update(body).digest('hex')
-      if (actualDigest !== file.digest) {
-        throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`)
-      }
-      const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? []
-      if (fileReferences.length === 0) {
-        obsoleteFiles.add(file.path)
-        prunedOrphans += 1
-        continue
-      }
-      const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`)
-      copyFileSync(file.path, temporary)
-      chmodSync(temporary, 0o755)
-      signingWork.push({ file, references: fileReferences, temporaryPath: temporary })
-    }
-    await runConcurrent(signingWork, concurrency, async (work) => {
-      await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`)
-    })
-    for (const work of signingWork) {
-      const signedBody = readFileSync(work.temporaryPath)
-      if (!isMachO(work.temporaryPath)) {
-        throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`)
-      }
-      const signedDigest = createHash('sha512').update(signedBody).digest('hex')
-      const mode = work.file.executable ? 0o755 : 0o644
-      const destination = signedCasPath(versionRoot, signedDigest, work.file.executable)
-      writeCasFile(destination, signedBody, mode)
-      const checkedAt = Date.now()
-      for (const reference of work.references) {
-        reference.record.checkedAt = checkedAt
-        reference.record.digest = signedDigest
-        reference.record.mode = mode
-        reference.record.size = signedBody.length
-        reference.row.changed = true
-      }
-      if (destination !== work.file.path) obsoleteFiles.add(work.file.path)
-    }
-    const changedRows = rows.filter(row => row.changed)
-    database.exec('BEGIN IMMEDIATE')
-    let committed = false
-    try {
-      const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)')
-      for (const row of changedRows) statement.run(row.key, packr.pack(row.value))
-      database.exec('COMMIT')
-      committed = true
-    } finally {
-      if (!committed) database.exec('ROLLBACK')
-    }
-    for (const path of obsoleteFiles) unlinkSync(path)
-    database.exec('VACUUM')
-    return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length }
-  } finally {
-    database.close()
-    rmSync(workRoot, { recursive: true, force: true })
-  }
-}
-
-/**
- * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index.
- * A signer rejection leaves the original CAS objects and package index unchanged.
- * @param storeRoot - Loose pnpm store prepared for the packaged seed.
- * @param appId - Electron application ID used as the signing identifier prefix.
- * @param expected - Company Developer ID identity and Team ID.
- * @param options - Optional signer and worker bound used by focused tests.
- * @returns Counts for release diagnostics after every signer completes and the index transaction commits.
- */
-export async function signMacOSSeedStore(
-  storeRoot: string,
-  appId: string,
-  expected: MacOSSigningEnvironment,
-  options: MacOSSeedStoreSigningOptions = {},
-): Promise<MacOSSeedStoreSigningResult> {
-  const roots = versionRoots(storeRoot)
-  if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`)
-  const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism())
-  if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
-    throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`)
-  }
-  const signer = options.signer ?? (async (path, identifier) => {
-    await signMacOSSeedCode(path, identifier, expected)
-  })
-  const results: MacOSSeedStoreSigningResult[] = []
-  for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency))
-  return results.reduce((total, current) => ({
-    signedFiles: total.signedFiles + current.signedFiles,
-    prunedOrphans: total.prunedOrphans + current.prunedOrphans,
-    updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows,
-  }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 })
-}
-
-/**
- * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime.
- * @param storeRoot - Loose or extracted pnpm store.
- * @param expected - Company Developer ID identity and Team ID.
- * @param verifier - Injectable signature verifier used by focused tests.
- * @returns Number of verified Mach-O files.
- */
-export function verifyMacOSSeedStore(
-  storeRoot: string,
-  expected: MacOSSigningEnvironment,
-  verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) },
-): number {
-  let count = 0
-  for (const root of versionRoots(storeRoot)) {
-    for (const file of casFiles(root)) {
-      verifier(file.path)
-      count += 1
-    }
-  }
-  return count
-}

+ 30 - 6
apps/desktop/scripts/package-target.ts

@@ -1,4 +1,4 @@
-/** Build one release target with matching Electron, Node.js, and seed architecture. */
+/** Build one release target with matching Electron, Node.js, and dsh architecture. */
 
 import { spawn } from 'node:child_process'
 import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
@@ -72,6 +72,25 @@ export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv)
     .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
 }
 
+/**
+ * Select signing and NSIS-compatible archive filters for electron-builder.
+ * @param environment - Target packaging environment.
+ * @param unsigned - Whether to create a local unsigned Windows artifact.
+ * @returns Packaging environment without certificate inputs for unsigned builds.
+ */
+export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv {
+  const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' }
+  // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries.
+  if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ'
+  if (!unsigned) return selected
+  return {
+    ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected))
+      .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))),
+    CSC_IDENTITY_AUTO_DISCOVERY: 'false',
+    DSH_DESKTOP_UNSIGNED: '1',
+  }
+}
+
 /**
  * Remove upload-only COS credentials from every packaging subprocess.
  * @param environment - Packaging command environment.
@@ -152,6 +171,7 @@ interface DesktopPackageInvocation {
   readonly target: DesktopPackageTarget
   readonly directory: boolean
   readonly prepareOnly: boolean
+  readonly unsigned: boolean
 }
 
 function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
@@ -178,14 +198,18 @@ export function parseDesktopPackageInvocation(
     options: {
       dir: { type: 'boolean', default: false },
       'prepare-only': { type: 'boolean', default: false },
+      unsigned: { type: 'boolean', default: false },
     },
   })
   if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
   const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
+  if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64')
+  if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only')
   return {
     target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
     directory: values.dir,
     prepareOnly: values['prepare-only'],
+    unsigned: values.unsigned,
   }
 }
 
@@ -240,7 +264,7 @@ async function main(): Promise<void> {
   const { target } = invocation
   const buildPaths = desktopTargetBuildPaths(target.name)
   const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
-  if (!invocation.prepareOnly) {
+  if (!invocation.prepareOnly && !invocation.unsigned) {
     rmSync(releaseRecordPath, { force: true })
     rmSync(`${releaseRecordPath}.tmp`, { force: true })
   }
@@ -250,9 +274,9 @@ async function main(): Promise<void> {
     DSH_DESKTOP_TARGET_PLATFORM: target.platform,
     DSH_DESKTOP_TARGET_ARCH: target.arch,
   }
-  const electronBuilderEnv = { ...targetEnv }
+  const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
   for (const name of WINDOWS_SIGNING_ENV_NAMES) {
-    if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
+    if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
   }
   await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
   await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
@@ -276,10 +300,10 @@ async function main(): Promise<void> {
   ], buildEnv, REPOSITORY_ROOT)
   await runPnpm(['run', 'prepare:runtime'], targetEnv)
   await runPnpm(['run', 'prepare:packages'], targetEnv)
-  await runPnpm(['run', 'prepare:seed'], targetEnv)
+  await runPnpm(['run', 'prepare:dsh'], targetEnv)
   if (invocation.prepareOnly) return
   await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
-  if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
+  if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }
 
 if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()

+ 159 - 0
apps/desktop/scripts/prepare-dsh.ts

@@ -0,0 +1,159 @@
+/** Materialize the complete production runtime before publishing Desktop resources. */
+
+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'
+import { createRuntimeProjectMetadata } from '../src/project-manager.ts'
+import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
+import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
+import {
+  DESKTOP_HOST_PACKAGE,
+  DESKTOP_HOST_RUNTIME_FILES,
+  DESKTOP_PACKAGES_DIR,
+  DESKTOP_PACKAGE_SET_FILE,
+  readDesktopCorePackageSet,
+  verifyDesktopCoreLockfile,
+} from '../src/core-package-set.ts'
+import { smokeDesktopRuntime } from './smoke-runtime.ts'
+import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import {
+  resolveDesktopAppId,
+  resolveMacOSSigningEnvironment,
+} from './desktop-release-environment.mjs'
+import {
+  signMacOSRuntime,
+} from './macos-runtime.ts'
+import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
+import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
+
+const APP_ROOT = resolve(import.meta.dirname, '..')
+const BUILD_PATHS = resolveDesktopTargetBuildPaths()
+const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh
+const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-'))
+const STORE_ROOT = join(BUILD_ROOT, 'store')
+const RUNTIME_ROOT = BUILD_PATHS.runtime
+const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
+const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
+const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
+const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
+
+function manifestVersion(path: string, subject: string): string {
+  const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
+  if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`)
+  return manifest.version
+}
+
+function desktopRelease(): DesktopRelease {
+  const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package')
+  const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package')
+  if (version !== dshVersion) {
+    throw new Error(`desktop runtime: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`)
+  }
+  const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record<string, unknown>
+  return parseDesktopRelease({
+    schemaVersion: 1,
+    version,
+    hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
+    nodeVersion: runtime.node,
+    pnpmVersion: runtime.pnpm,
+  })
+}
+
+function runPnpm(args: readonly string[]): Promise<void> {
+  return new Promise((resolvePromise, reject) => {
+    const [command, ...commandArgs] = args
+    if (command === undefined) throw new Error('desktop runtime: pnpm command is required')
+    const config = join(PNPM_BUILD_STATE, 'config')
+    const userConfig = join(config, 'npmrc')
+    mkdirSync(config, { recursive: true })
+    writeFileSync(userConfig, '')
+    const child = spawn(NODE, [
+      PNPM,
+      '--config.registry=https://registry.npmjs.org/',
+      `--config.store-dir=${STORE_ROOT}`,
+      '--config.enable-global-virtual-store=false',
+      `--config.userconfig=${userConfig}`,
+      command,
+      ...commandArgs,
+    ], {
+      cwd: BUILD_ROOT,
+      env: {
+        ...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)
+        ))),
+        NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
+        NPM_CONFIG_STORE_DIR: STORE_ROOT,
+        NPM_CONFIG_USERCONFIG: userConfig,
+        PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
+        XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
+        XDG_CONFIG_HOME: config,
+        XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
+      },
+      stdio: 'inherit',
+    })
+    child.once('error', reject)
+    child.once('close', (code, signal) => {
+      if (code === 0) resolvePromise()
+      else reject(new Error(`desktop runtime: pnpm exited with ${String(code ?? signal)}`))
+    })
+  })
+}
+
+async function main(): Promise<void> {
+  rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
+  rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
+  mkdirSync(STORE_ROOT, { recursive: true })
+  try {
+    const release = desktopRelease()
+    copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
+    cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
+    createRuntimeProjectMetadata(BUILD_ROOT, release)
+    await runPnpm(['install', '--lockfile-only'])
+    verifyDesktopCoreLockfile(
+      readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
+      readDesktopCorePackageSet(BUILD_ROOT, release.version),
+    )
+    await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])
+    const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
+    const targetName = resolveDesktopBuildTarget()
+    const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
+    const modules = join(BUILD_ROOT, 'node_modules')
+    mkdirSync(DSH_OUTPUT_ROOT, { recursive: true })
+    cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
+      recursive: true, dereference: true,
+      filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined,
+    })
+    writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({
+      name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module',
+      dependencies: Object.fromEntries(packageSet.packages.map(entry => [entry.name, entry.version])),
+    }, undefined, 2)}\n`)
+    for (const file of DESKTOP_HOST_RUNTIME_FILES) {
+      if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', DESKTOP_HOST_PACKAGE, file))) {
+        throw new Error(`desktop runtime: missing private Host file ${file}`)
+      }
+    }
+    if (process.platform === 'darwin') {
+      await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
+    }
+    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) {
+    rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
+    throw error
+  } finally {
+    rmSync(BUILD_ROOT, { recursive: true, force: true })
+    rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
+  }
+}
+
+await main()

+ 0 - 200
apps/desktop/scripts/prepare-seed.ts

@@ -1,200 +0,0 @@
-/** Build the release seed through the same embedded pnpm used on first launch. */
-
-import { spawn } from 'node:child_process'
-import { createHash } from 'node:crypto'
-import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { delimiter, dirname, join, relative, resolve, sep } from 'node:path'
-import { createSeedMetadata } from '../src/project-manager.ts'
-import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
-import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
-import {
-  DESKTOP_HOST_PACKAGE,
-  DESKTOP_HOST_RUNTIME_FILES,
-  DESKTOP_PACKAGES_DIR,
-  DESKTOP_PACKAGE_SET_FILE,
-  readDesktopCorePackageSet,
-  verifyDesktopCoreLockfile,
-} from '../src/core-package-set.ts'
-import {
-  archivePnpmStore,
-  extractPnpmStoreArchives,
-  removePnpmProjectRegistrations,
-} from '../src/seed-store.ts'
-import {
-  resolveDesktopAppId,
-  resolveMacOSSigningEnvironment,
-} from './desktop-release-environment.mjs'
-import {
-  signMacOSSeedStore,
-  verifyMacOSSeedStore,
-} from './macos-seed-store.ts'
-import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
-
-const APP_ROOT = resolve(import.meta.dirname, '..')
-const BUILD_PATHS = resolveDesktopTargetBuildPaths()
-const SEED_OUTPUT_ROOT = BUILD_PATHS.seed
-const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-'))
-const STORE_ROOT = join(SEED_ROOT, 'store')
-const RUNTIME_ROOT = BUILD_PATHS.runtime
-const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm
-const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
-const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
-const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
-
-function manifestVersion(path: string, subject: string): string {
-  const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
-  if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`)
-  return manifest.version
-}
-
-function desktopRelease(): DesktopRelease {
-  const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package')
-  const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package')
-  if (version !== dshVersion) {
-    throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`)
-  }
-  const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record<string, unknown>
-  return parseDesktopRelease({
-    schemaVersion: 1,
-    version,
-    hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
-    nodeVersion: runtime.node,
-    pnpmVersion: runtime.pnpm,
-  })
-}
-
-function runPnpm(args: readonly string[]): Promise<void> {
-  return new Promise((resolvePromise, reject) => {
-    const [command, ...commandArgs] = args
-    if (command === undefined) throw new Error('desktop seed: pnpm command is required')
-    const config = join(PNPM_BUILD_STATE, 'config')
-    const userConfig = join(config, 'npmrc')
-    mkdirSync(config, { recursive: true })
-    writeFileSync(userConfig, '')
-    const child = spawn(NODE, [
-      PNPM,
-      '--config.registry=https://registry.npmjs.org/',
-      `--config.store-dir=${STORE_ROOT}`,
-      '--config.enable-global-virtual-store=false',
-      `--config.userconfig=${userConfig}`,
-      command,
-      ...commandArgs,
-    ], {
-      cwd: SEED_ROOT,
-      env: {
-        ...Object.fromEntries(Object.entries(process.env).filter(([name]) => (
-          !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
-        ))),
-        NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
-        NPM_CONFIG_STORE_DIR: STORE_ROOT,
-        NPM_CONFIG_USERCONFIG: userConfig,
-        PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
-        XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
-        XDG_CONFIG_HOME: config,
-        XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
-      },
-      stdio: 'inherit',
-    })
-    child.once('error', reject)
-    child.once('close', (code, signal) => {
-      if (code === 0) resolvePromise()
-      else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`))
-    })
-  })
-}
-
-function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] {
-  const files: string[] = []
-  const visit = (dir: string): void => {
-    for (const entry of readdirSync(dir, { withFileTypes: true })) {
-      const path = join(dir, entry.name)
-      if (entry.isDirectory()) visit(path)
-      else if (entry.isFile()) files.push(path)
-      else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`)
-    }
-  }
-  visit(root)
-  return files.sort().map((path) => {
-    const body = readFileSync(path)
-    return {
-      path: relative(root, path).split(sep).join('/'),
-      bytes: statSync(path).size,
-      sha256: createHash('sha256').update(body).digest('hex'),
-    }
-  })
-}
-
-async function verifyOfflineInstallation(release: DesktopRelease): Promise<void> {
-  const installedModules = join(SEED_ROOT, 'node_modules')
-  try {
-    await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
-    const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/'))
-    for (const file of DESKTOP_HOST_RUNTIME_FILES) {
-      if (!existsSync(join(hostRoot, file))) {
-        throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`)
-      }
-    }
-  } finally {
-    rmSync(installedModules, { recursive: true, force: true })
-  }
-}
-
-async function main(): Promise<void> {
-  rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true })
-  rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
-  mkdirSync(STORE_ROOT, { recursive: true })
-  try {
-    const release = desktopRelease()
-    copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE))
-    cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
-    createSeedMetadata(SEED_ROOT, release)
-    await runPnpm(['install', '--lockfile-only'])
-    verifyDesktopCoreLockfile(
-      readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'),
-      readDesktopCorePackageSet(SEED_ROOT, release.version),
-    )
-    const installedModules = join(SEED_ROOT, 'node_modules')
-    await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts'])
-    rmSync(installedModules, { recursive: true, force: true })
-    rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
-    await verifyOfflineInstallation(release)
-    const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform
-    let signedMachOFiles: number | undefined
-    let macOSSigning: ReturnType<typeof resolveMacOSSigningEnvironment> | undefined
-    if (targetPlatform === 'darwin') {
-      macOSSigning = resolveMacOSSigningEnvironment(process.env)
-      const signing = await signMacOSSeedStore(
-        STORE_ROOT,
-        resolveDesktopAppId(process.env),
-        macOSSigning,
-      )
-      signedMachOFiles = signing.signedFiles
-      process.stdout.write(
-        `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`,
-      )
-      await verifyOfflineInstallation(release)
-    }
-    removePnpmProjectRegistrations(STORE_ROOT)
-    archivePnpmStore(SEED_ROOT, STORE_ROOT)
-    if (macOSSigning !== undefined && signedMachOFiles !== undefined) {
-      const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-'))
-      try {
-        extractPnpmStoreArchives(SEED_ROOT, extractedStore)
-        const verified = verifyMacOSSeedStore(extractedStore, macOSSigning)
-        if (verified !== signedMachOFiles) {
-          throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`)
-        }
-      } finally {
-        rmSync(extractedStore, { recursive: true, force: true })
-      }
-    }
-    const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json')
-    writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`)
-    cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true })
-  } finally {
-    rmSync(SEED_ROOT, { recursive: true, force: true })
-  }
-}
-
-await main()

+ 40 - 0
apps/desktop/scripts/runtime-file-policy.ts

@@ -0,0 +1,40 @@
+/** Desktop-only omissions from already installed production npm packages. */
+
+/**
+ * Identify build and diagnostic files omitted from the immutable Desktop runtime.
+ * Unrecognized assets and target runtime binaries are retained. Paths name the copied
+ * node_modules tree, including nested package containers.
+ * @param path - Path relative to the production node_modules directory.
+ * @param target - Platform and architecture of the bundled Node executable.
+ * @returns Omission reason, or undefined when the entry must be copied.
+ */
+export function desktopRuntimeFileExclusion(
+  path: string, target: { platform: NodeJS.Platform; arch: string },
+): string | undefined {
+  const parts = path.split(/[\\/]/u)
+  if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) {
+    return 'package-manager metadata'
+  }
+  const file = parts.at(-1) ?? ''
+  if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map'
+  if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration'
+  if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache'
+  const packageParts = parts.slice(parts.lastIndexOf('node_modules') + 1)
+  const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1
+  const name = packageParts.slice(0, nameParts).join('/')
+  const entry = packageParts.slice(nameParts).join('/')
+  if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) {
+    return 'fs-ext compiler output'
+  }
+  if (name === 'fs-ext' && /^build\/(?:binding\.sln|config\.gypi|fs_ext\.vcxproj(?:\.filters)?)$/u.test(entry)) {
+    return 'fs-ext build configuration'
+  }
+  if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures'
+  if (name === 'node-pty' && entry.startsWith('prebuilds/')) {
+    const platform = packageParts[nameParts + 1]
+    if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform'
+    if (file.endsWith('.pdb')) return 'node-pty debug symbols'
+  }
+  if (name === '@koromix/koffi-win32-x64' && entry === 'win32_x64/koffi.lib') return 'Koffi import library'
+  return undefined
+}

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

@@ -0,0 +1,58 @@
+/** Boot the materialized target runtime without access to a user's Harness profile. */
+
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DesktopHostProcess } from '../src/host-process.ts'
+import { createPluginProfile } from '../src/project-manager.ts'
+import { linkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
+import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
+
+/**
+ * Prove the final resource tree boots and serves its matching Web frontend.
+ * @param root - Materialized dsh resources.
+ * @param node - Prepared target Node executable.
+ * @param runtime - Verified resource descriptor.
+ */
+export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise<void> {
+  const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
+  const profile = join(home, 'profiles', 'desktop')
+  const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home })
+  try {
+    createPluginProfile(profile)
+    const pluginName = 'desktop-runtime-smoke-plugin'
+    const plugin = join(profile, 'node_modules', pluginName)
+    mkdirSync(plugin, { recursive: true })
+    const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis')
+    if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package')
+    writeFileSync(join(plugin, 'package.json'), JSON.stringify({
+      name: pluginName, version: '1.0.0', type: 'module', exports: './index.js',
+      peerDependencies: { '@deepseek-ai/cordis': cordis.version }, dsh: { bundle: { patch: './bundle.yml' } },
+    }))
+    writeFileSync(join(plugin, 'index.js'), `
+import { Context } from '@deepseek-ai/cordis'
+export function apply(ctx) {
+  if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance')
+}
+`)
+    writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n    - id: desktop-runtime-smoke-plugin\n      name: desktop-runtime-smoke-plugin\n')
+    const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
+      dependencies: Record<string, string>
+      dsh: { profile: { bundles: string[] } }
+    }
+    manifest.dependencies[pluginName] = '1.0.0'
+    manifest.dsh.profile.bundles.push(pluginName)
+    writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest))
+    linkDesktopHostPackages(profile, root, runtime)
+    validateDesktopPluginGraph(profile, root, runtime, [pluginName])
+    const ready = await host.start()
+    if (ready.dshVersion !== runtime.release.version) throw new Error('desktop runtime: Host reported another dsh release')
+    const response = await host.fetch(new Request('dsh-app://app/'))
+    if (response.status !== 200 || !(await response.text()).includes('<html')) {
+      throw new Error('desktop runtime: packaged frontend smoke failed')
+    }
+  } finally {
+    await host.stop()
+    rmSync(home, { recursive: true, force: true })
+  }
+}

+ 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
+}

+ 6 - 6
apps/desktop/scripts/verify-macos-signature.d.mts

@@ -8,31 +8,31 @@ import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
 export function assertMacOSSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
 
 /**
- * Require the signature properties Apple validates for executable seed content.
+ * Require the signature properties Apple validates for executable runtime content.
  * @param details - Output from `codesign --display --verbose=4`.
  * @param expected - Public release identity.
  */
-export function assertMacOSSeedSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
+export function assertMacOSRuntimeSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
 
 /**
- * Sign one Mach-O file embedded in the seed store.
+ * Sign one Mach-O file embedded in the runtime tree.
  * @param path - Writable standalone Mach-O file.
  * @param identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
  * @param expected - Public release identity.
  * @returns Resolves after codesign exits successfully.
  */
-export function signMacOSSeedCode(
+export function signMacOSRuntimeCode(
   path: string,
   identifier: string,
   expected: MacOSSigningEnvironment,
 ): Promise<void>
 
 /**
- * Verify one Mach-O file embedded in the seed store.
+ * Verify one Mach-O file embedded in the runtime tree.
  * @param path - Mach-O file to inspect.
  * @param expected - Public release identity.
  */
-export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void
+export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvironment): void
 
 /**
  * Verify the full application signature and its release owner.

+ 11 - 11
apps/desktop/scripts/verify-macos-signature.mjs

@@ -1,4 +1,4 @@
-/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */
+/** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */
 
 import { spawn, spawnSync } from 'node:child_process'
 import { resolve } from 'node:path'
@@ -21,19 +21,19 @@ export function assertMacOSSignatureDetails(details, expected) {
 }
 
 /**
- * Require the signature properties Apple validates for executable seed content.
+ * Require the signature properties Apple validates for executable runtime content.
  * @param {string} details - Output from `codesign --display --verbose=4`.
  * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  * @returns {void}
  */
-export function assertMacOSSeedSignatureDetails(details, expected) {
+export function assertMacOSRuntimeSignatureDetails(details, expected) {
   assertMacOSSignatureDetails(details, expected)
   const fields = details.split(/\r?\n/u).map(line => line.trim())
   if (!fields.some(line => /^Timestamp=.+/u.test(line))) {
-    throw new Error('desktop macOS signing: seed signature has no secure timestamp')
+    throw new Error('desktop macOS signing: runtime signature has no secure timestamp')
   }
   if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) {
-    throw new Error('desktop macOS signing: seed signature does not enable hardened runtime')
+    throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime')
   }
 }
 
@@ -60,7 +60,7 @@ function runAppleCommand(command, args, label) {
 }
 
 /**
- * Execute one Apple release tool without blocking other independent seed signers.
+ * Execute one Apple release tool without blocking other independent runtime signers.
  * @param {string} command - Absolute executable path.
  * @param {readonly string[]} args - Tool arguments.
  * @param {string} label - Stable diagnostic name.
@@ -106,13 +106,13 @@ function runCodeSign(args) {
 }
 
 /**
- * Sign one Mach-O file embedded in the seed store.
+ * Sign one Mach-O file embedded in the runtime tree.
  * @param {string} path - Writable standalone Mach-O file.
  * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
  * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  * @returns {Promise<void>} Resolves after codesign exits successfully.
  */
-export async function signMacOSSeedCode(path, identifier, expected) {
+export async function signMacOSRuntimeCode(path, identifier, expected) {
   await runAppleCommandAsync('/usr/bin/codesign', [
     '--force',
     '--sign', expected.signingIdentity,
@@ -124,15 +124,15 @@ export async function signMacOSSeedCode(path, identifier, expected) {
 }
 
 /**
- * Verify one Mach-O file embedded in the seed store.
+ * Verify one Mach-O file embedded in the runtime tree.
  * @param {string} path - Mach-O file to inspect.
  * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
  * @returns {void}
  */
-export function verifyMacOSSeedCode(path, expected) {
+export function verifyMacOSRuntimeCode(path, expected) {
   runCodeSign(['--verify', '--strict', '--verbose=2', path])
   const details = runCodeSign(['--display', '--verbose=4', path])
-  assertMacOSSeedSignatureDetails(details, expected)
+  assertMacOSRuntimeSignatureDetails(details, expected)
 }
 
 /**

+ 148 - 0
apps/desktop/src/backend-controller.ts

@@ -0,0 +1,148 @@
+/** Owns one backend startup and its quiescent teardown independently of windows. */
+
+import { desktopErrorState } from './startup-error.ts'
+
+/** Backend availability presented by the desktop window. */
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean }
+
+/** Child lifecycle owned by the desktop backend controller. */
+export interface DesktopBackendHost {
+  /** @returns Readiness after the child accepts application requests. */
+  start(): Promise<unknown>
+  /** @returns Completion of child exit. */
+  stop(): Promise<void>
+}
+
+interface Attempt<Host> {
+  cancelled: boolean
+  failure?: Error
+  host?: Host
+  cleanup?: Promise<void>
+}
+
+/** Serializes retries and prevents children from outliving a closed window. */
+export class DesktopBackendController<Host extends DesktopBackendHost> {
+  private current: DesktopBackendState = { phase: 'starting' }
+  private attempt: Attempt<Host> | undefined
+  private pending: Promise<void> | undefined
+  private stopping: Promise<void> | undefined
+  private closed = false
+
+  /**
+   * @param createHost - Allocate a child and route its fatal failures to the supplied callback.
+   * @param publish - Receive availability changes until the controller closes.
+   */
+  constructor(
+    private readonly createHost: (onFailure: (error: Error) => void) => Host,
+    private readonly publish: (state: DesktopBackendState) => void,
+  ) {}
+
+  /** Current availability, including the last startup or child failure. */
+  get state(): DesktopBackendState { return this.current }
+
+  /** Child available to application requests; absent during startup and teardown. */
+  get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined }
+
+  /**
+   * Prepare the profile and start one child; concurrent callers share the attempt.
+   * @param prepare - Profile preparation that must finish before spawning.
+   * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure.
+   */
+  start(prepare: () => Promise<void>): Promise<void> {
+    if (this.closed) return Promise.reject(new Error('desktop backend is closed'))
+    if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping'))
+    if (this.pending !== undefined) return this.pending
+    if (this.current.phase === 'ready') return Promise.resolve()
+    const previous = this.attempt
+    const attempt: Attempt<Host> = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) }
+    this.attempt = attempt
+    this.update({ phase: 'starting' })
+    const pending = Promise.resolve().then(async () => {
+      try {
+        await previous?.cleanup
+        delete attempt.cleanup
+        if (attempt.cancelled) return
+        await prepare()
+        // stop() can cancel this attempt while preparation is pending.
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (attempt.cancelled) return
+        const host = this.createHost((error) => { this.failed(attempt, error) })
+        attempt.host = host
+        await host.start()
+        if (attempt.failure !== undefined) throw attempt.failure
+        // stop() can cancel this attempt while the child starts.
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (!attempt.cancelled) this.update({ phase: 'ready' })
+      } catch (error) {
+        const cancelled = attempt.cancelled
+        attempt.cancelled = true
+        let failure = error
+        try { await this.cleanup(attempt) } catch (cleanupError) {
+          if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed')
+        }
+        if (!cancelled) this.update(desktopErrorState(failure))
+        throw failure
+      }
+    }).finally(() => { if (this.pending === pending) this.pending = undefined })
+    this.pending = pending
+    return pending
+  }
+
+  /**
+   * Stop pending preparation and the child before allowing another start.
+   * @returns Completion of pending work and child exit; rejects if cleanup fails.
+   */
+  stop(): Promise<void> {
+    if (this.stopping !== undefined) return this.stopping
+    const attempt = this.attempt
+    if (attempt !== undefined) attempt.cancelled = true
+    if (!this.closed) this.update({ phase: 'starting' })
+    const pending = this.pending
+    const stopping = Promise.allSettled([
+      attempt === undefined ? Promise.resolve() : this.cleanup(attempt),
+      pending,
+    ]).then((results) => {
+      const cleanup = results[0]
+      if (cleanup.status === 'rejected') throw cleanup.reason
+      if (this.attempt === attempt) this.attempt = undefined
+    }).finally(() => { if (this.stopping === stopping) this.stopping = undefined })
+    this.stopping = stopping
+    return stopping
+  }
+
+  /**
+   * Permanently prevent startup and suppress further availability notifications.
+   * @returns Completion of pending work and child exit; rejects if cleanup fails.
+   */
+  close(): Promise<void> {
+    this.closed = true
+    return this.stop()
+  }
+
+  private cleanup(attempt: Attempt<Host>): Promise<void> {
+    if (attempt.cleanup === undefined) {
+      attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() })
+    }
+    return attempt.cleanup
+  }
+
+  private failed(attempt: Attempt<Host>, error: Error): void {
+    if (this.attempt !== attempt || attempt.cancelled) return
+    attempt.failure = error
+    if (this.current.phase !== 'ready') return
+    attempt.cancelled = true
+    const cleanup = this.cleanup(attempt)
+    this.update(desktopErrorState(error))
+    void cleanup.catch((cleanupError: unknown) => {
+      if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop')))
+    })
+  }
+
+  private update(state: DesktopBackendState): void {
+    if (this.closed) return
+    this.current = state
+    try { this.publish(state) } catch (error) {
+      console.error('desktop backend state listener failed', error)
+    }
+  }
+}

+ 1 - 1
apps/desktop/src/core-package-set.ts

@@ -124,7 +124,7 @@ export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string
 
 /**
  * Verify every local tarball and reject extra package files before pnpm executes them.
- * @param projectDir - Seed or profile directory containing the package set.
+ * @param projectDir - Build directory containing the package set.
  * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron.
  * @returns The verified package set.
  */

+ 20 - 6
apps/desktop/src/host-process.ts

@@ -83,31 +83,39 @@ export class DesktopHostProcess {
   })
   private exitPromise: Promise<void> | undefined
   private stderr = ''
+  private failureReported = false
 
   /**
    * @param node - absolute bundled upstream Node.js executable.
-   * @param projectDir - active or staged desktop npm project.
+   * @param runtimeDir - immutable packages carried by the current application.
+   * @param projectDir - active or staged desktop plugin profile.
    * @param inspectPort - optional loopback inspector port for workspace development.
+   * @param environment - Child environment; runtime and package-manager overrides are removed.
+   * @param onFailure - Receives the first fatal child or transport failure, including after readiness.
    */
   constructor(
     private readonly node: string,
+    private readonly runtimeDir: string,
     private readonly projectDir: string,
     private readonly inspectPort?: number,
+    private readonly environment: NodeJS.ProcessEnv = process.env,
+    private readonly onFailure?: (error: Error) => void,
   ) {}
 
   /** Start the child once and resolve only after its complete composition is active. */
   async start(): Promise<DesktopHostReady> {
     if (this.child !== undefined) return this.readyPromise
-    const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
+    const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
     const child = spawn(this.node, [
       ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
       entry,
+      this.runtimeDir,
       this.projectDir,
       ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']),
     ], {
       cwd: this.projectDir,
-      env: Object.fromEntries(Object.entries(process.env).filter(([name]) => (
-        name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
+      env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
+        name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
       ))),
       stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
     })
@@ -144,7 +152,7 @@ export class DesktopHostProcess {
     })
     child.once('error', (error) => { this.fail(error) })
     this.exitPromise = new Promise<void>((resolve) => {
-      child.once('exit', (code) => {
+      child.once('close', (code) => {
         const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
         if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
         else this.fail(new Error(`dsh desktop host stopped${suffix}`))
@@ -273,7 +281,7 @@ export class DesktopHostProcess {
   private send(message: DesktopHostCommand): void {
     const child = this.child
     if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable')
-    child.send(message)
+    child.send(message, (error) => { if (error !== null) this.fail(error) })
   }
 
   private acceptResponseBytes(chunk: Buffer): void {
@@ -400,6 +408,12 @@ export class DesktopHostProcess {
 
   private fail(error: Error): void {
     this.readyReject(error)
+    if (!this.failureReported) {
+      this.failureReported = true
+      try { this.onFailure?.(error) } catch (listenerError) {
+        console.error('desktop host failure listener failed', listenerError)
+      }
+    }
     for (const pending of this.pending.values()) {
       void pending.requestReader?.cancel(error).catch(() => undefined)
       if (pending.controller === undefined) pending.reject(error)

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

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

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

@@ -3,6 +3,14 @@
 export const en = {
   application: 'Application',
   startupFailed: 'DeepSeek Harness could not start',
+  startupLoading: 'Starting DeepSeek Harness…',
+  startupLoadingDescription: 'Your workspace will open when it is ready.',
+  startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.',
+  startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
+  startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.',
+  restartApplication: 'Close and restart',
+  resetConfiguration: 'Reset Desktop and retry',
+  disableThirdPartyPlugins: 'Disable all third-party plugins and retry',
   pluginsMenu: 'Desktop Plugins…',
   pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)',
   checkUpdatesMenu: 'Check for Updates…',
@@ -20,6 +28,13 @@ export const en = {
   pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins',
   pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.',
   refresh: 'Refresh',
+  enable: 'Enable',
+  disable: 'Disable',
+  disabled: 'Disabled',
+  retry: 'Retry startup',
+  disableAll: 'Disable all plugins and retry',
+  recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.',
+  changingActivation: 'Changing plugin activation…',
   npmPackage: 'npm package',
   install: 'Install',
   installed: 'Installed',
@@ -42,6 +57,14 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 export const zh = {
   application: '应用',
   startupFailed: 'DeepSeek Harness 无法启动',
+  startupLoading: '正在启动 DeepSeek Harness…',
+  startupLoadingDescription: '准备就绪后将自动打开工作区。',
+  startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。',
+  startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
+  startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。',
+  restartApplication: '关闭并重启',
+  resetConfiguration: '重置 Desktop 并重试',
+  disableThirdPartyPlugins: '禁用全部第三方插件并重试',
   pluginsMenu: '桌面插件…',
   pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)',
   checkUpdatesMenu: '检查更新…',
@@ -59,6 +82,13 @@ export const zh = {
   pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
   pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
   refresh: '刷新',
+  enable: '启用',
+  disable: '禁用',
+  disabled: '已禁用',
+  retry: '重试启动',
+  disableAll: '禁用全部插件并重试',
+  recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。',
+  changingActivation: '正在更改插件启用状态…',
   npmPackage: 'npm 包',
   install: '安装',
   installed: '已安装',

+ 217 - 93
apps/desktop/src/main.ts

@@ -15,16 +15,31 @@ import {
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
+import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
+import { desktopErrorState } from './startup-error.ts'
+import { 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'))
+  app.relaunch()
+  app.quit()
+  return Promise.resolve()
+}
 
-function errorOf(reason: unknown, fallback: string): Error {
-  return reason instanceof Error ? reason : new Error(fallback)
+async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
+  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable())
+  const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
+  emergencyPages.set(window, { url, message, busy: false })
+  await window.loadURL(url)
 }
 
 protocol.registerSchemesAsPrivileged([{
@@ -49,7 +64,7 @@ const MIME: Readonly<Record<string, string>> = {
 interface RuntimeResources {
   readonly node: string
   readonly pnpm: string
-  readonly seed: string
+  readonly dsh: string
 }
 
 function runtimeResources(): RuntimeResources {
@@ -58,15 +73,8 @@ function runtimeResources(): RuntimeResources {
     ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
   const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
     ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
-  const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed')
-  return { node, pnpm, seed }
-}
-
-function developmentProject(): string | undefined {
-  const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR
-  if (configured === undefined || configured === '') return undefined
-  if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications')
-  return resolve(configured)
+  const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh')
+  return { node, pnpm, dsh }
 }
 
 function developmentHostInspectPort(enabled: boolean): number | undefined {
@@ -79,13 +87,13 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
   return port
 }
 
-function createWindow(preload: string): BrowserWindow {
+function createWindow(preload: string, show = false): BrowserWindow {
   const window = new BrowserWindow({
     width: 1280,
     height: 840,
     minWidth: 880,
     minHeight: 600,
-    show: false,
+    show,
     webPreferences: {
       preload,
       nodeIntegration: false,
@@ -97,6 +105,15 @@ function createWindow(preload: string): BrowserWindow {
   window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
   window.webContents.on('will-navigate', (event, url) => {
     if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault()
+    const page = emergencyPages.get(window)
+    if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return
+    const action = new URL(url)
+    if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return
+    if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return
+    page.busy = true
+    void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => {
+      if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`)
+    }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false })
   })
   return window
 }
@@ -133,12 +150,13 @@ async function serveShellAsset(request: Request): Promise<Response> {
 async function main(): Promise<void> {
   const resources = runtimeResources()
   const paths = resolveDesktopPaths()
-  const development = developmentProject()
+  const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project')
   const activeProject = development ?? paths.profile
-  const hostInspectPort = developmentHostInspectPort(development !== undefined)
   const manager = new DesktopProjectManager(paths, resources)
-  if (development === undefined) manager.recover()
-  let host: DesktopHostProcess | undefined
+  profileRecoveryAvailable = () => development === undefined && manager.canRecoverProfile()
+  let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
+  let quitting = false
+  let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
@@ -147,6 +165,56 @@ async function main(): Promise<void> {
   const messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
+  const startupUrl = `${SCHEME}://shell/startup.html`
+  const applicationUrl = `${SCHEME}://app/index.html`
+  let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
+  let emergencyDocument = false
+
+  const showEmergencyError = async (error: unknown): Promise<void> => {
+    if (quitting || emergencyDocument) return
+    emergencyDocument = true
+    const diagnostic = desktopErrorState(error).message
+    pageError = { phase: 'error', message: diagnostic }
+    if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
+  }
+
+  const navigateMain = (url: string): Promise<void> => {
+    const window = mainWindow
+    if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
+    if (navigation?.window === window && navigation.url === url) return navigation.promise
+    const next = { window, url, promise: Promise.resolve() }
+    next.promise = window.loadURL(url).catch((error: unknown) => {
+      if (quitting || window.isDestroyed() || navigation !== next) return
+      navigation = undefined
+      throw error
+    })
+    navigation = next
+    return next.promise
+  }
+  const backendState = (): DesktopBackendState => {
+    const state = pageError ?? backend.state
+    return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state
+  }
+  const publishBackend = (state: DesktopBackendState): void => {
+    for (const window of BrowserWindow.getAllWindows()) {
+      window.webContents.send(DESKTOP_IPC.backendState, state)
+    }
+  }
+  const backend = new DesktopBackendController((onFailure) => {
+    if (development === undefined) manager.assertProfileRuntime(activeProject)
+    const hostInspectPort = developmentHostInspectPort(development !== undefined)
+    const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject,
+      hostInspectPort, process.env, onFailure)
+    return {
+      start: () => host.start(),
+      stop: () => host.stop(),
+      fetch: (request: Request) => host.fetch(request),
+    }
+  }, (state) => {
+    if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
+    publishBackend(backendState())
+    if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
+  })
 
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
     updateState = state
@@ -156,76 +224,73 @@ async function main(): Promise<void> {
     return state
   }
 
-  const startHost = async (projectDir = activeProject): Promise<DesktopHostProcess> => {
-    const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort)
-    await next.start()
-    return next
-  }
   const hooks: DesktopProjectHooks = {
-    healthCheck: async (projectDir) => {
-      const active = host
-      host = undefined
-      await active?.stop()
-      let healthFailure: unknown
-      let probe: DesktopHostProcess | undefined
-      try {
-        probe = await startHost(projectDir)
-        await probe.stop()
-      } catch (error) {
-        healthFailure = error
-        await probe?.stop().catch(() => undefined)
-      }
-      let restartFailure: unknown
-      if (active !== undefined) {
-        try {
-          host = await startHost()
-        } catch (error) {
-          restartFailure = error
-        }
-      }
-      if (healthFailure !== undefined && restartFailure !== undefined) {
-        throw new AggregateError([
-          errorOf(healthFailure, 'desktop project: staged health check failed'),
-          errorOf(restartFailure, 'desktop project: active backend restart failed'),
-        ], 'desktop project: staged health check and active backend restart failed')
-      }
-      if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed')
-      if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed')
-    },
-    beforeActivate: async () => {
-      const active = host
-      host = undefined
-      await active?.stop()
-    },
-    afterActivate: async () => {
-      host = await startHost()
-    },
+    beforeChange: () => backend.stop(),
+    afterChange: () => backend.start(async () => {}),
   }
 
-  if (development === undefined) {
-    await manager.applyRelease(resources.seed, app.getVersion(), {
-      ...hooks,
-      beforeActivate: async () => {},
-      afterActivate: async () => {},
-    })
+  recoverApplication = async (action): Promise<void> => {
+    await startup?.catch(() => undefined)
+    await backend.stop()
+    if (action === 'restart') {
+      app.relaunch()
+      app.quit()
+      return
+    }
+    if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice)
+    if (action === 'reset') await manager.resetConfiguration(hooks)
+    else await manager.mutate({ type: 'plugins-disable-all' }, hooks)
+    emergencyDocument = false
+    pageError = undefined
+    navigation = undefined
+    await navigateMain(applicationUrl)
+  }
+
+  const showStartupError = async (error: unknown): Promise<void> => {
+    if (quitting) return
+    pageError = desktopErrorState(error)
+    try { await navigateMain(startupUrl) }
+    catch (navigationError) {
+      await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
+    }
+    publishBackend(backendState())
+  }
+  const reconcileBackend = (): Promise<void> => {
+    startup ??= (async () => {
+      pageError = undefined
+      await navigateMain(startupUrl)
+      await backend.start(async () => {
+        if (development === undefined) {
+          await manager.applyRelease()
+        }
+      })
+      if (backend.host !== undefined) await navigateMain(applicationUrl)
+    })().catch(async (error: unknown) => {
+      await showStartupError(error)
+      throw error
+    }).finally(() => { startup = undefined })
+    return startup
   }
-  host = await startHost()
 
   const updates = new DesktopUpdateCoordinator(
     publishUpdate,
     async () => {
       shellInstallerOwnsQuit = true
-      const active = host
-      host = undefined
-      await active?.stop()
+      await backend.stop()
     },
   )
 
   protocol.handle(SCHEME, (request) => {
     const url = new URL(request.url)
-    if (url.hostname === 'shell') return serveShellAsset(request)
+    if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
+      if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
+        void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
+          .catch((error: unknown) => { console.error(error) })
+      }
+      return response
+    })
     if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 }))
-    const active = host
+    const active = backend.host
     if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 }))
     return active.fetch(request)
   })
@@ -235,8 +300,16 @@ async function main(): Promise<void> {
     if (development !== undefined) {
       throw new Error('dsh desktop: plugin package changes require a packaged application')
     }
-    await manager.mutate(mutation, hooks)
-    if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload()
+    await startup?.catch(() => undefined)
+    pageError = undefined
+    await navigateMain(startupUrl)
+    try {
+      await manager.mutate(mutation, hooks)
+      await navigateMain(applicationUrl)
+    } catch (error) {
+      await showStartupError(error)
+      throw error
+    }
   }
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
@@ -261,6 +334,42 @@ async function main(): Promise<void> {
     }
     return mutate(event, { type: 'plugin-update', name, version })
   })
+  ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => {
+    if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
+    return mutate(event, { type: 'plugin-toggle', name, enabled })
+  })
+  ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
+  ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
+    assertDesktopSender(event, ['shell'])
+    return backendState()
+  })
+  ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
+    assertDesktopSender(event, ['shell'])
+    await reconcileBackend()
+    focusPrimaryWindow()
+  })
+  ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
+    assertDesktopSender(event, ['shell'])
+    try {
+      await recoverApplication('restart')
+    } catch (error) {
+      await showStartupError(error)
+    }
+  })
+  ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => {
+    assertDesktopSender(event, ['shell'])
+    if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application')
+    const failure = backendState()
+    if (failure.phase !== 'error') {
+      throw new Error('Desktop profile reset requires a startup failure')
+    }
+    await startup?.catch(() => undefined)
+    try {
+      await recoverApplication('reset')
+    } catch (error) {
+      await showStartupError(error)
+    }
+  })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
     return updates.check()
@@ -341,17 +450,26 @@ async function main(): Promise<void> {
   }]))
 
   const createMainWindow = (): BrowserWindow => {
-    const window = createWindow(appPreload)
+    const window = createWindow(appPreload, true)
     mainWindow = window
-    window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    window.webContents.on('preload-error', (_event, _path, error) => {
+      void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
+    })
+    window.webContents.on('render-process-gone', (_event, details) => {
+      navigation = undefined
+      emergencyDocument = false
+      void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
+        .catch((failure: unknown) => { console.error(failure) })
+    })
     return window
   }
   focusPrimaryWindow = () => {
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
-      const replacement = createMainWindow()
-      void replacement.loadURL(`${SCHEME}://app/index.html`)
+      createMainWindow()
+      void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl)
+        .catch((error: unknown) => { console.error(error) })
       return
     }
     if (window.isMinimized()) window.restore()
@@ -359,14 +477,6 @@ async function main(): Promise<void> {
     window.focus()
   }
 
-  mainWindow = createMainWindow()
-  await mainWindow.loadURL(`${SCHEME}://app/index.html`)
-  if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
-    mainWindow.webContents.openDevTools({ mode: 'detach' })
-  }
-  publishUpdate(updateState)
-  setTimeout(() => { void checkAndPrompt(false) }, 10_000)
-
   app.on('activate', () => {
     if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
   })
@@ -374,13 +484,23 @@ async function main(): Promise<void> {
     if (process.platform !== 'darwin') app.quit()
   })
   app.on('before-quit', (event) => {
-    if (shellInstallerOwnsQuit) return
-    if (host === undefined) return
+    if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
-    const active = host
-    host = undefined
-    void active.stop().finally(() => { app.quit() })
+    quitting = true
+    void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
   })
+
+  mainWindow = createMainWindow()
+  await reconcileBackend().catch(() => undefined)
+  // Window lifecycle callbacks run while backend startup is pending.
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  if (quitting) return
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
+    mainWindow.webContents.openDevTools({ mode: 'detach' })
+  }
+  publishUpdate(updateState)
+  setTimeout(() => { void checkAndPrompt(false) }, 10_000)
 }
 
 const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
@@ -392,6 +512,10 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk
   if (diagnosticFile !== undefined) {
     await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
   }
-  dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message)
+  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)
 })

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

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

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

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

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

@@ -1,5 +1,24 @@
-/** Minimal marker that selects the desktop custom-protocol API carrier. */
+/** Startup controls for shell documents; application documents receive only the carrier marker. */
 
-import { contextBridge } from 'electron'
+import { contextBridge, ipcRenderer } from 'electron'
+import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts'
+import type { DesktopBackendState } from './backend-controller.ts'
 
-contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })
+const startup: DshDesktopStartupApi = {
+  protocolVersion: 1,
+  locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
+  backend: {
+    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['backend']['status']>,
+    subscribe(listener) {
+      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
+      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
+      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
+    },
+  },
+  disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
+  restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
+  resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
+}
+
+contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
+  ? startup : { protocolVersion: 1 })

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

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

+ 238 - 0
apps/desktop/src/profile-packages.ts

@@ -0,0 +1,238 @@
+/** Desktop-owned host links and validation of the external plugin dependency graph. */
+
+import { createHash } from 'node:crypto'
+import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { satisfies } from 'semver'
+import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
+
+/** Applied runtime identity and the only links Desktop may replace. */
+export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json'
+
+/** Durable ownership of a package-directory link. */
+export interface DesktopPackageLink {
+  readonly name: string
+  readonly target: string
+}
+
+/** Runtime identity and managed links; package preparation may still be pending. */
+export interface DesktopProfileState {
+  readonly schemaVersion: 1
+  readonly runtimeId: string
+  readonly version: string
+  readonly nodeVersion: string
+  readonly platform: string
+  readonly arch: string
+  readonly lockHash: string
+  readonly links: readonly DesktopPackageLink[]
+}
+
+const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
+
+function record(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function stat(path: string): ReturnType<typeof lstatSync> | undefined {
+  try { return lstatSync(path) } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return undefined
+  }
+}
+
+function inside(root: string, path: string): boolean {
+  const child = relative(root, path)
+  return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`))
+}
+
+/**
+ * Read profile state without interpreting an unpublished predecessor format.
+ * @param profile - Desktop profile directory.
+ * @returns Validated state, or undefined for an uninitialized profile.
+ */
+export function readDesktopProfileState(profile: string): DesktopProfileState | undefined {
+  const path = join(profile, DESKTOP_PROFILE_STATE)
+  if (!existsSync(path)) return undefined
+  const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
+  if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string'
+    || !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string'
+    || typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string'
+    || typeof value.lockHash !== 'string' || !Array.isArray(value.links)) {
+    throw new Error('desktop profile: invalid runtime state')
+  }
+  const links = value.links.map((link: unknown): DesktopPackageLink => {
+    if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name)
+      || typeof link.target !== 'string' || !isAbsolute(link.target)) {
+      throw new Error('desktop profile: invalid managed link')
+    }
+    return { name: link.name, target: link.target }
+  })
+  if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link')
+  return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion,
+    platform: value.platform, arch: value.arch, lockHash: value.lockHash, links }
+}
+
+/**
+ * Hash the plugin lockfile, including the empty-profile case.
+ * @param profile - Desktop profile directory.
+ * @returns Lockfile content identity.
+ */
+export function desktopPluginLockHash(profile: string): string {
+  const lock = join(profile, 'pnpm-lock.yaml')
+  return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex')
+}
+
+/**
+ * Remove only recorded host links, without following even broken targets.
+ * @param profile - Desktop profile.
+ */
+export function unlinkDesktopHostPackages(profile: string): void {
+  for (const link of readDesktopProfileState(profile)?.links ?? []) {
+    const path = join(profile, 'node_modules', link.name)
+    const entry = stat(path)
+    if (entry === undefined) continue
+    if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) {
+      throw new Error(`desktop profile: refusing to replace unowned package ${link.name}`)
+    }
+    unlinkSync(path)
+  }
+}
+
+/**
+ * Bind an external profile to this application's real package directories.
+ * @param profile - Candidate profile.
+ * @param root - Current immutable runtime directory.
+ * @param runtime - Verified release descriptor.
+ */
+export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void {
+  unlinkDesktopHostPackages(profile)
+  const links = runtime.sharedPackages.map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) }))
+  for (const link of links) {
+    const path = join(profile, 'node_modules', link.name)
+    if (stat(path) !== undefined) throw new Error(`desktop profile: plugin installed reserved host package ${link.name}`)
+    mkdirSync(dirname(path), { recursive: true })
+    symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir')
+  }
+  const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version,
+    nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch,
+    lockHash: desktopPluginLockHash(profile), links }
+  writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 })
+}
+
+interface PackageManifest {
+  readonly name: string
+  readonly version: string
+  readonly dependencies: Readonly<Record<string, string>>
+  readonly optionalDependencies: Readonly<Record<string, string>>
+  readonly peerDependencies: Readonly<Record<string, string>>
+  readonly optionalPeers: ReadonlySet<string>
+}
+
+function manifest(path: string): PackageManifest {
+  const value: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'))
+  if (!record(value) || typeof value.name !== 'string' || typeof value.version !== 'string') {
+    throw new Error(`desktop profile: invalid package manifest ${path}`)
+  }
+  const dependencies = (key: string): Record<string, string> => {
+    const field = value[key]
+    if (field === undefined) return {}
+    if (!record(field) || Object.entries(field).some(([name, spec]) => !PACKAGE_NAME.test(name) || typeof spec !== 'string')) {
+      throw new Error(`desktop profile: invalid ${key} in ${path}`)
+    }
+    return field as Record<string, string>
+  }
+  const optionalPeers = new Set<string>()
+  if (record(value.peerDependenciesMeta)) {
+    for (const [name, meta] of Object.entries(value.peerDependenciesMeta)) {
+      if (record(meta) && meta.optional === true) optionalPeers.add(name)
+    }
+  }
+  return { name: value.name, version: value.version, dependencies: dependencies('dependencies'),
+    optionalDependencies: dependencies('optionalDependencies'), peerDependencies: dependencies('peerDependencies'), optionalPeers }
+}
+
+function packageFrom(anchor: string, name: string): string | undefined {
+  if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`)
+  for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) {
+    const path = join(modules, name)
+    if (existsSync(join(path, 'package.json'))) return realpathSync.native(path)
+  }
+  return undefined
+}
+
+/**
+ * Prove active plugin dependencies stay local and share the host's exact package instances.
+ * @param profile - Profile with its generated host links present.
+ * @param root - Immutable runtime directory.
+ * @param runtime - Verified shared package inventory.
+ * @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required.
+ */
+export function validateDesktopPluginGraph(
+  profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[],
+): void {
+  const profileRoot = realpathSync.native(profile)
+  const shared = new Map(runtime.sharedPackages.map((entry) => {
+    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}`)
+  }
+  if (activePlugins.length === 0) return
+  const scanned = new Set<string>()
+  const scan = (modules: string): void => {
+    if (!existsSync(modules)) return
+    if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`)
+    const directory = realpathSync.native(modules)
+    if (scanned.has(directory)) return
+    scanned.add(directory)
+    for (const entry of readdirSync(modules, { withFileTypes: true })) {
+      if (entry.name.startsWith('.')) continue
+      const path = join(modules, entry.name)
+      if (entry.name.startsWith('@')) { scan(path); continue }
+      if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`)
+      const canonical = realpathSync.native(path)
+      const info = manifest(canonical)
+      const host = shared.get(info.name)
+      if (host !== undefined) {
+        if (canonical !== host || path !== join(profile, 'node_modules', info.name)) {
+          throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`)
+        }
+        continue
+      }
+      if (entry.isSymbolicLink()) throw new Error(`desktop profile: linked private package ${path}`)
+      if (!inside(profileRoot, canonical)) throw new Error(`desktop profile: package resolves outside profile: ${path}`)
+      scan(join(path, 'node_modules'))
+    }
+  }
+  scan(join(profile, 'node_modules'))
+  const visited = new Set<string>()
+  const visit = (path: string, chain: string): void => {
+    if (visited.has(path)) return
+    visited.add(path)
+    const info = manifest(path)
+    const deps = { ...info.dependencies, ...info.optionalDependencies }
+    for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) {
+      const peer = name in info.peerDependencies
+      const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies
+      const target = packageFrom(path, name)
+      if (target === undefined && optional) continue
+      if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`)
+      const host = shared.get(name)
+      if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`)
+      if (host !== undefined ? target !== host : !inside(profileRoot, target)) {
+        throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`)
+      }
+      const dependency = manifest(target)
+      if (peer && !satisfies(dependency.version, range)) {
+        throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`)
+      }
+      if (host === undefined) visit(target, `${chain} -> ${name}`)
+    }
+  }
+  for (const name of activePlugins) {
+    const path = packageFrom(profile, name)
+    if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
+    visit(path, name)
+  }
+}

+ 194 - 308
apps/desktop/src/project-manager.ts

@@ -1,11 +1,8 @@
-/** Transactional owner of the reserved desktop profile and its private pnpm state. */
+/** In-place owner of the reserved desktop profile and its private pnpm state. */
 
+import { valid } from 'semver'
 import { spawn } from 'node:child_process'
-import { createHash, randomUUID } from 'node:crypto'
 import {
-  constants,
-  copyFileSync,
-  cpSync,
   existsSync,
   fsyncSync,
   ftruncateSync,
@@ -13,41 +10,33 @@ import {
   mkdirSync,
   openSync,
   closeSync,
-  readdirSync,
   readFileSync,
-  renameSync,
-  rmSync,
+  readdirSync,
+  realpathSync,
   unlinkSync,
   writeFileSync,
   writeSync,
 } from 'node:fs'
-import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { delimiter, dirname, join, resolve, sep } from 'node:path'
 import {
-  DESKTOP_PACKAGES_DIR,
-  DESKTOP_PACKAGE_SET_FILE,
   DESKTOP_HOST_PACKAGE,
   desktopCorePackageOverrides,
-  desktopDshPackageSpec,
-  readDesktopCorePackageSet,
   verifyDesktopCorePackageSet,
 } from './core-package-set.ts'
 import type { DesktopPaths } from './paths.ts'
-import { parseDesktopRelease, type DesktopRelease } from './release.ts'
-import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts'
-
-/** Files the package transaction copies between active and staging projects. */
-const DESKTOP_PROJECT_FILES = [
-  'package.json',
-  'pnpm-lock.yaml',
-  'pnpm-workspace.yaml',
-  'desktop-release.json',
-  DESKTOP_PACKAGE_SET_FILE,
-] as const
+import { removeOwnedDirectory } from './owned-directory.ts'
+import type { DesktopRelease } from './release.ts'
+import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
+import {
+  desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
+  unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
+} from './profile-packages.ts'
 
 /** Desktop plugin record derived from the installed profile. */
 export interface DesktopPluginRecord {
   readonly name: string
   readonly version: string
+  readonly enabled: boolean
 }
 
 /** Installed desktop project manifest slice. */
@@ -63,28 +52,19 @@ interface DesktopProjectManifest {
   }
 }
 
-/** Journaled activation step used for crash recovery. */
-interface DesktopPendingTransaction {
-  readonly schemaVersion: 1
-  readonly id: string
-  readonly stagingProfile: string
-  readonly step: 'prepared' | 'active-moved' | 'staging-activated'
-}
-
 /** Exact executables the desktop shell bundles. */
 export interface DesktopRuntimeExecutables {
   readonly node: string
   readonly pnpm: string
+  readonly dsh: string
 }
 
-/** Hooks that bind project replacement to backend lifecycle and health. */
+/** Hooks that stop the backend before profile writes and restart it after success. */
 export interface DesktopProjectHooks {
-  /** Prove the staged dependency graph while the active backend is stopped. */
-  healthCheck(projectDir: string): Promise<void>
-  /** Stop the active backend and await process exit before directory moves. */
-  beforeActivate(): Promise<void>
-  /** Start the selected active project after commit or rollback. */
-  afterActivate(): Promise<void>
+  /** Stop the active backend and await process exit before modifying its files. */
+  beforeChange(): Promise<void>
+  /** Start the modified profile after package preparation succeeds. */
+  afterChange(): Promise<void>
 }
 
 /** Supported dependency mutation. */
@@ -92,12 +72,8 @@ export type DesktopProjectMutation =
   | { readonly type: 'plugin-add'; readonly spec: string }
   | { readonly type: 'plugin-remove'; readonly name: string }
   | { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
-
-interface DesktopSeedIntegrityRecord {
-  readonly path: string
-  readonly bytes: number
-  readonly sha256: string
-}
+  | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
+  | { readonly type: 'plugins-disable-all' }
 
 const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
 const DSH_PACKAGE = '@deepseek-ai/dsh'
@@ -133,19 +109,10 @@ function workspaceFile(overrides: Readonly<Record<string, string>> = {}): string
   return `packages:\n  - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n  node-pty: true\n  koffi: true\n  fs-ext: true\n  ${JSON.stringify(coreBuildKey)}: true\n  '@google/genai': false\n  protobufjs: false\n  node-addon-require-builtin: false\n`
 }
 
-function releaseFile(projectDir: string): DesktopRelease {
-  return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json')))
-}
-
 function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null
 }
 
-function isDescendant(root: string, target: string): boolean {
-  const child = relative(root, target)
-  return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)
-}
-
 function assertPackageName(name: string): void {
   if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`)
 }
@@ -155,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)}`)
   }
@@ -179,94 +146,20 @@ export function packageNameFromSpec(spec: string): string | undefined {
   return name
 }
 
-function removeOwnedDirectory(path: string): void {
-  if (!existsSync(path)) return
-  const stat = lstatSync(path)
-  if (stat.isSymbolicLink()) {
-    unlinkSync(path)
-    return
-  }
-  if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
-  rmSync(path, { recursive: true })
-}
-
-function copyMetadata(source: string, target: string): void {
-  mkdirSync(target, { recursive: true, mode: 0o700 })
-  for (const filename of DESKTOP_PROJECT_FILES) {
-    const from = join(source, filename)
-    if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL)
-  }
-  cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), {
-    recursive: true,
-    force: false,
-    errorOnExist: true,
-  })
-}
-
-function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] {
-  const files: DesktopSeedIntegrityRecord[] = []
-  const visit = (directory: string): void => {
-    for (const entry of readdirSync(directory, { withFileTypes: true })) {
-      const path = join(directory, entry.name)
-      const relativePath = path.slice(root.length + 1).split(sep).join('/')
-      if (relativePath === 'integrity.json') continue
-      if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`)
-      if (entry.isDirectory()) {
-        visit(path)
-        continue
-      }
-      if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`)
-      const body = readFileSync(path)
-      files.push({
-        path: relativePath,
-        bytes: body.byteLength,
-        sha256: createHash('sha256').update(body).digest('hex'),
-      })
-    }
-  }
-  visit(root)
-  return files.sort((left, right) => left.path.localeCompare(right.path))
-}
-
-/** Verify the packaged offline seed before any content enters writable desktop state. */
-export function verifySeedIntegrity(seedDir: string): void {
-  const integrityPath = join(seedDir, 'integrity.json')
-  const integrity = readJson(integrityPath)
-  if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) {
-    throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`)
-  }
-  const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => {
-    if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/')
-      || record.path.split('/').includes('..') || typeof record.bytes !== 'number'
-      || !Number.isSafeInteger(record.bytes) || record.bytes < 0
-      || typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) {
-      throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`)
-    }
-    return { path: record.path, bytes: record.bytes, sha256: record.sha256 }
-  }).sort((left, right) => left.path.localeCompare(right.path))
-  const actual = seedFiles(seedDir)
-  if (JSON.stringify(actual) !== JSON.stringify(expected)) {
-    throw new Error('desktop seed: integrity verification failed')
-  }
-}
-
 function projectManifest(projectDir: string): DesktopProjectManifest {
   const path = join(projectDir, 'package.json')
   const value = readJson(path)
   const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined
   const profile = isRecord(dsh?.profile) ? dsh.profile : undefined
   if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true
-    || typeof value.version !== 'string' || !isRecord(value.dependencies)
+    || typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies))
     || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) {
     throw new Error(`desktop project: invalid desktop profile manifest ${path}`)
   }
-  const manifest = value as unknown as DesktopProjectManifest
-  const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version)
-  const expectedOverrides = desktopCorePackageOverrides(packageSet)
-  if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet)
-    || Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec)
-    || readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) {
-    throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`)
+  const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest
+  if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name)
+    || typeof version !== 'string' || valid(version) !== version)) {
+    throw new Error('desktop project: plugin dependencies must use exact registry versions')
   }
   return manifest
 }
@@ -285,7 +178,7 @@ function profilePluginNames(projectDir: string): readonly string[] {
 }
 
 function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] {
-  return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name))
+  return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name))
 }
 
 function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void {
@@ -296,7 +189,7 @@ function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPlugin
       ...manifest.dsh,
       profile: {
         ...manifest.dsh.profile,
-        bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)],
+        bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)],
       },
     },
   } satisfies DesktopProjectManifest)
@@ -322,12 +215,13 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin
   if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) {
     throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`)
   }
-  return { name: requestedName, version: manifest.version }
+  return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) }
 }
 
-/** Transactional desktop npm project manager. */
+/** Desktop npm project manager with direct writes and no rollback. */
 export class DesktopProjectManager {
   private lockDescriptor: number | undefined
+  private descriptor: DesktopRuntimeDescriptor | undefined
 
   /**
    * @param paths - Electron-owned package state and reserved desktop profile paths.
@@ -338,136 +232,156 @@ export class DesktopProjectManager {
     readonly runtime: DesktopRuntimeExecutables,
   ) {}
 
-  /** Recover an interrupted directory replacement before reading the active project. */
-  recover(): void {
-    if (!existsSync(this.paths.pending)) return
-    const value = readJson(this.paths.pending)
-    if (!isRecord(value) || value.schemaVersion !== 1
-      || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string'
-      || !isDescendant(this.paths.staging, value.stagingProfile)
-      || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) {
-      throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`)
-    }
-    const pending: DesktopPendingTransaction = {
-      schemaVersion: 1,
-      id: value.id,
-      stagingProfile: value.stagingProfile,
-      step: value.step,
-    }
-    if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
-      mkdirSync(dirname(this.paths.profile), { recursive: true })
-      renameSync(this.paths.rollback, this.paths.profile)
-    }
-    removeOwnedDirectory(pending.stagingProfile)
-    unlinkSync(this.paths.pending)
-  }
-
   /** Read the active desktop plugin inventory. */
   listPlugins(): readonly DesktopPluginRecord[] {
     if (!existsSync(this.paths.profile)) return []
     return pluginRecords(this.paths.profile)
   }
 
-  /** Read the exact dsh version installed in the active desktop project. */
+  /**
+   * Reinitialize the profile, deleting configuration and third-party packages without a backup.
+   * @param hooks - Stop the Host before resetting files; restart after preparation succeeds.
+   * @returns Completion of reset; the held lock and shared product data are preserved.
+   */
+  async resetConfiguration(hooks: DesktopProjectHooks): Promise<void> {
+    await this.withLock(async () => {
+      await hooks.beforeChange()
+      this.descriptor = this.readRuntime()
+      for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) {
+        const path = join(this.paths.profile, entry.name)
+        if (path === this.paths.lock) continue
+        if (entry.isDirectory()) removeOwnedDirectory(path)
+        else unlinkSync(path)
+      }
+      createPluginProfile(this.paths.profile)
+      this.prepareProfile(this.paths.profile)
+      await hooks.afterChange()
+    })
+  }
+
+  /** Read the dsh version supplied by this application's verified resources. */
   dshVersion(): string {
-    if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
-    return this.installedPackageVersion(DSH_PACKAGE)
+    return this.currentRuntime().release.version
+  }
+
+  /** Read the release most recently applied to the active profile. */
+  releaseVersion(): string {
+    const state = readDesktopProfileState(this.paths.profile)
+    if (state === undefined) throw new Error('desktop project: active profile has no runtime state')
+    return state.version
   }
 
-  private installedPackageVersion(packageName: string): string {
-    const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json')
-    const manifest = readJson(manifestPath)
-    if (!isRecord(manifest) || typeof manifest.version !== 'string') {
-      throw new Error(`desktop project: installed ${packageName} package has no version`)
+  /** 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')
     }
-    assertVersion(manifest.version)
-    return manifest.version
   }
 
-  /** Read the release version applied to the active desktop project. */
-  releaseVersion(): string {
-    if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
-    return releaseFile(this.paths.profile).version
+  /** @returns Whether application resources support profile recovery. */
+  canRecoverProfile(): boolean {
+    return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
   }
 
-  /** Install or reconcile the active project to the Electron package's exact release. */
-  async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise<boolean> {
+  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 {
+    this.descriptor = undefined
+    return readDesktopRuntime(this.runtime.dsh)
+  }
+
+  private prepareProfile(projectDir: string): void {
+    const runtime = this.currentRuntime()
+    linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
+    validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir))
+  }
+
+  /** Read release metadata and reconcile its external profile without installing core packages. */
+  async applyRelease(): Promise<boolean> {
     return this.withLock(async () => {
-      this.recover()
-      verifySeedIntegrity(seedDir)
-      const target = releaseFile(seedDir)
-      verifyDesktopCorePackageSet(seedDir, target.version)
-      if (target.version !== electronVersion) {
-        throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`)
-      }
-      if (existsSync(this.paths.profile) && this.releaseVersion() === target.version
-        && this.dshVersion() === target.version
-        && this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) {
-        verifyDesktopCorePackageSet(this.paths.profile, target.version)
+      const target = this.readRuntime()
+      this.descriptor = target
+      const previous = readDesktopProfileState(this.paths.profile)
+      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)))) {
         return false
       }
-      this.mergeSeedPnpmState(seedDir)
-      const stagingProfile = this.newStagingProfile()
-      try {
-        if (existsSync(this.paths.profile)) {
-          const plugins = pluginRecords(this.paths.profile)
-          copyMetadata(seedDir, stagingProfile)
-          await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
-          if (plugins.length > 0) {
-            await this.runPnpm(stagingProfile, [
-              'add',
-              ...plugins.map(plugin => `${plugin.name}@${plugin.version}`),
-              '--save-exact',
-              '--offline',
-            ])
-            writeProfilePlugins(stagingProfile, plugins)
-          }
-        } else {
-          copyMetadata(seedDir, stagingProfile)
-          await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
-        }
-        await hooks.healthCheck(stagingProfile)
-        await this.activate(stagingProfile, hooks)
-        return true
-      } catch (error) {
-        removeOwnedDirectory(stagingProfile)
-        throw error
-      }
+      if (previous === undefined) createPluginProfile(this.paths.profile)
+      await this.reconcileProfile(this.paths.profile, previous)
+      return true
     })
   }
 
-  /** Apply one exact dependency mutation through a staging project. */
+  /** Modify the current profile while its backend is stopped; failures retain partial changes. */
   async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
     await this.withLock(async () => {
-      this.recover()
+      this.currentRuntime()
       if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
-      verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion())
-      const stagingProfile = this.newStagingProfile()
+      await hooks.beforeChange()
+      if (mutation.type === 'plugins-disable-all') {
+        const manifest = projectManifest(this.paths.profile)
+        writeJson(join(this.paths.profile, 'package.json'), {
+          ...manifest,
+          dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } },
+        })
+        this.prepareProfile(this.paths.profile)
+        await hooks.afterChange()
+        return
+      }
+      const previous = readDesktopProfileState(this.paths.profile)
+      const packagesChanged = mutation.type !== 'plugin-toggle'
+      if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile)
       try {
-        copyMetadata(this.paths.profile, stagingProfile)
-        await this.applyMutation(stagingProfile, mutation)
-        await hooks.healthCheck(stagingProfile)
-        await this.activate(stagingProfile, hooks)
-      } catch (error) {
-        removeOwnedDirectory(stagingProfile)
-        throw error
+        await this.applyMutation(this.paths.profile, mutation)
+      } finally {
+        if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime())
       }
+      await this.reconcileProfile(this.paths.profile, previous, packagesChanged)
+      await hooks.afterChange()
     })
   }
 
-  private newStagingProfile(): string {
-    const path = join(this.paths.staging, randomUUID(), 'profile')
-    mkdirSync(path, { recursive: true, mode: 0o700 })
-    return path
+  private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise<void> {
+    const target = this.currentRuntime()
+    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: DesktopProjectMutation): Promise<void> {
+  private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
     switch (mutation.type) {
       case 'plugin-add': {
         const requestedName = packageNameFromSpec(mutation.spec)
-        if (requestedName === undefined) throw new Error('desktop project: plugin package name is required')
-        await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact'])
-        const installed = inspectPlugin(projectDir, requestedName)
+        if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) {
+          throw new Error(`desktop project: cannot install host-owned package ${requestedName}`)
+        }
+        await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts'])
+        const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true }
         const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name)
         writeProfilePlugins(
           projectDir,
@@ -477,21 +391,21 @@ export class DesktopProjectManager {
       }
       case 'plugin-remove': {
         assertPackageName(mutation.name)
-        if (!profilePluginNames(projectDir).includes(mutation.name)) {
+        if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
           throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
         }
         const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name)
-        await this.runPnpm(projectDir, ['remove', mutation.name])
+        await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true'])
         writeProfilePlugins(projectDir, remaining)
         return
       }
       case 'plugin-update':
         assertPackageName(mutation.name)
         assertVersion(mutation.version)
-        if (!profilePluginNames(projectDir).includes(mutation.name)) {
+        if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
           throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
         }
-        await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact'])
+        await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts'])
         {
           const installed = inspectPlugin(projectDir, mutation.name)
           writeProfilePlugins(
@@ -500,54 +414,20 @@ export class DesktopProjectManager {
           )
         }
         return
+      case 'plugin-toggle': {
+        assertPackageName(mutation.name)
+        const plugins = pluginRecords(projectDir)
+        if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`)
+        writeProfilePlugins(projectDir, plugins.map(plugin => (
+          plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin
+        )))
+        return
+      }
       default:
         mutation satisfies never
     }
   }
 
-  private mergeSeedPnpmState(seedDir: string): void {
-    const transactionRoot = join(this.paths.staging, randomUUID())
-    const extractedStore = join(transactionRoot, 'store')
-    try {
-      extractPnpmStoreArchives(seedDir, extractedStore)
-      mergePnpmStore(extractedStore, this.paths.pnpm.store)
-    } finally {
-      removeOwnedDirectory(transactionRoot)
-    }
-  }
-
-  private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise<void> {
-    const pending: DesktopPendingTransaction = {
-      schemaVersion: 1,
-      id: basename(dirname(stagingProfile)),
-      stagingProfile,
-      step: 'prepared',
-    }
-    writeJson(this.paths.pending, pending)
-    await hooks.beforeActivate()
-    let activeMoved = false
-    try {
-      removeOwnedDirectory(this.paths.rollback)
-      mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 })
-      writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
-      if (existsSync(this.paths.profile)) {
-        renameSync(this.paths.profile, this.paths.rollback)
-        activeMoved = true
-      }
-      mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 })
-      writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction)
-      renameSync(stagingProfile, this.paths.profile)
-      await hooks.afterActivate()
-      unlinkSync(this.paths.pending)
-    } catch (error) {
-      if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile)
-      if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile)
-      if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending)
-      await hooks.afterActivate().catch(() => undefined)
-      throw error
-    }
-  }
-
   private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
     const [command, ...commandArgs] = args
     if (command === undefined) throw new Error('desktop project: pnpm command is required')
@@ -558,8 +438,9 @@ export class DesktopProjectManager {
     const npmrc = join(this.paths.pnpm.config, 'npmrc')
     if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 })
     const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
-      !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(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,
@@ -585,19 +466,7 @@ export class DesktopProjectManager {
         },
         stdio: ['ignore', 'pipe', 'pipe'],
       })
-      const childPid = child.pid
-      if (childPid === undefined) {
-        child.kill('SIGKILL')
-        reject(new Error('desktop project: pnpm did not report a process id'))
-        return
-      }
-      try {
-        this.writeLockOwner(childPid)
-      } catch (error) {
-        child.kill('SIGKILL')
-        reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm'))
-        return
-      }
+      let failure: Error | undefined
       let diagnostics = ''
       let completed = false
       const appendDiagnostics = (chunk: string): void => {
@@ -618,9 +487,10 @@ export class DesktopProjectManager {
         }
         settleChild()
       }
-      child.once('error', (error) => { complete(() => { reject(error) }) })
+      child.once('error', (error) => { failure = error })
       child.once('close', (code, signal) => {
         complete(() => {
+          if (failure !== undefined) { reject(failure); return }
           if (code === 0) {
             settle()
             return
@@ -630,6 +500,13 @@ export class DesktopProjectManager {
           ))
         })
       })
+      try {
+        if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id')
+        this.writeLockOwner(child.pid)
+      } catch (error) {
+        failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')
+        child.kill('SIGKILL')
+      }
     })
   }
 
@@ -643,7 +520,8 @@ export class DesktopProjectManager {
   }
 
   private async withLock<T>(operation: () => Promise<T>): Promise<T> {
-    mkdirSync(this.paths.root, { recursive: true, mode: 0o700 })
+    mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 })
+    if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link')
     let descriptor: number
     try {
       descriptor = openSync(this.paths.lock, 'wx', 0o600)
@@ -682,10 +560,10 @@ export class DesktopProjectManager {
   }
 }
 
-/** Create seed metadata for one exact Electron and dsh release. */
-export function createSeedMetadata(seedDir: string, release: DesktopRelease): void {
-  mkdirSync(seedDir, { recursive: true, mode: 0o700 })
-  const packageSet = verifyDesktopCorePackageSet(seedDir, release.version)
+/** Create build-only project metadata for materializing the signed runtime. */
+export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void {
+  mkdirSync(projectDir, { recursive: true, mode: 0o700 })
+  const packageSet = verifyDesktopCorePackageSet(projectDir, release.version)
   const manifest: DesktopProjectManifest = {
     name: PROJECT_NAME,
     private: true,
@@ -693,13 +571,12 @@ export function createSeedMetadata(seedDir: string, release: DesktopRelease): vo
     dependencies: desktopCorePackageOverrides(packageSet),
     dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
   }
-  writeJson(join(seedDir, 'package.json'), manifest)
+  writeJson(join(projectDir, 'package.json'), manifest)
   writeFileSync(
-    join(seedDir, 'pnpm-workspace.yaml'),
+    join(projectDir, 'pnpm-workspace.yaml'),
     workspaceFile(desktopCorePackageOverrides(packageSet)),
     { mode: 0o600 },
   )
-  writeJson(join(seedDir, 'desktop-release.json'), release)
 }
 
 /**
@@ -721,5 +598,14 @@ 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. */
+export function createPluginProfile(projectDir: string): void {
+  mkdirSync(projectDir, { recursive: true, mode: 0o700 })
+  writeJson(join(projectDir, 'package.json'), {
+    name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {},
+    dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
+  } satisfies DesktopProjectManifest)
+  writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
 }

+ 2 - 2
apps/desktop/src/release.ts

@@ -1,9 +1,9 @@
-/** Immutable version identity shared by one Electron shell and its dsh seed. */
+/** Immutable version identity shared by one Electron shell and its bundled dsh runtime. */
 
 import { valid } from 'semver'
 import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts'
 
-/** Release facts embedded in the seed and copied into the active desktop project. */
+/** Release facts embedded in the bundled runtime descriptor. */
 export interface DesktopRelease {
   readonly schemaVersion: 1
   /** Exact version used by both Electron and `@deepseek-ai/dsh`. */

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

@@ -0,0 +1,222 @@
+/** Relocatable, integrity-recorded production packages carried by one Desktop release. */
+
+import { createHash } from 'node:crypto'
+import { lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs'
+import { isAbsolute, join, relative, sep } from 'node:path'
+import { promisify } from 'node:util'
+import { valid } from 'semver'
+import { DESKTOP_HOST_PACKAGE } from './core-package-set.ts'
+import { parseDesktopRelease, type DesktopRelease } from './release.ts'
+
+/** Descriptor at the root of the immutable Desktop resource tree. */
+export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json'
+
+/** Host-owned package available to external plugins through a directory link. */
+export interface DesktopSharedPackage {
+  readonly name: string
+  readonly version: string
+  readonly path: string
+}
+
+/** Final bytes and executable permissions of a runtime file. */
+export interface DesktopRuntimeFile {
+  readonly path: string
+  readonly bytes: number
+  readonly sha256: string
+  readonly executable: boolean
+}
+
+/** One signed application's production dependency tree. */
+export interface DesktopRuntimeDescriptor {
+  readonly schemaVersion: 1
+  readonly release: DesktopRelease
+  readonly platform: NodeJS.Platform
+  readonly arch: string
+  readonly sharedPackages: readonly DesktopSharedPackage[]
+  readonly files: readonly DesktopRuntimeFile[]
+}
+
+const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
+const readRuntimeFile = promisify(readFile)
+
+function record(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Resolve one portable resource path without permitting traversal or absolute paths.
+ * @param root - Runtime root.
+ * @param path - Slash-separated relative path from durable metadata.
+ * @returns Absolute resource path.
+ */
+export function runtimePath(root: string, path: string): string {
+  if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':')
+    || path.split('/').some(part => part === '' || part === '.' || part === '..')) {
+    throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`)
+  }
+  return join(root, ...path.split('/'))
+}
+
+function runtimeFiles(root: string): { path: string; name: string }[] {
+  const files: { path: string; name: string }[] = []
+  const visit = (directory: string): void => {
+    for (const entry of readdirSync(directory, { withFileTypes: true })) {
+      const path = join(directory, entry.name)
+      const name = relative(root, path).split(sep).join('/')
+      if (name === DESKTOP_RUNTIME_FILE) continue
+      if (entry.isDirectory()) visit(path)
+      else if (entry.isFile()) files.push({ path, name })
+      else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`)
+    }
+  }
+  visit(root)
+  return files
+}
+
+function runtimeFile(path: string, name: string, body: Buffer): DesktopRuntimeFile {
+  return { path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'),
+    // Windows has no portable Unix executable permission bits.
+    executable: process.platform !== 'win32' && (lstatSync(path).mode & 0o111) !== 0 }
+}
+
+/**
+ * Inventory a materialized runtime without following links or including its descriptor.
+ * @param root - Self-contained runtime directory.
+ * @returns Sorted final-file inventory; executable permissions are false on Windows.
+ */
+export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
+  return runtimeFiles(root).map(({ path, name }) => runtimeFile(path, name, readFileSync(path)))
+    .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
+}
+
+async function inventoryRuntimeForVerification(root: string): Promise<DesktopRuntimeFile[]> {
+  const entries = runtimeFiles(root)
+  const files: DesktopRuntimeFile[] = []
+  const remaining = entries.values()
+  let failed = false
+  const workers = Array.from({ length: Math.min(8, entries.length) }, async () => {
+    while (!failed) {
+      const next = remaining.next()
+      if (next.done) return
+      const { path, name } = next.value
+      try {
+        files.push(runtimeFile(path, name, await readRuntimeFile(path)))
+      } catch (error) {
+        failed = true
+        throw error
+      }
+    }
+  })
+  // Failure returns only after every outstanding file read has closed its descriptor.
+  const results = await Promise.allSettled(workers)
+  for (const result of results) if (result.status === 'rejected') throw result.reason
+  return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
+}
+
+/**
+ * Seal the final runtime tree after materialization and native signing.
+ * @param root - Runtime output directory.
+ * @param release - Matching shell, dsh, Host, and executable versions.
+ * @param sharedNames - Release-owned packages supplied to plugins.
+ * @param target - Platform and architecture selected by runtime preparation.
+ * @returns Descriptor written beside the production packages.
+ */
+export function writeDesktopRuntime(
+  root: string, release: DesktopRelease, sharedNames: readonly string[],
+  target: { platform: NodeJS.Platform; arch: string } = process,
+): DesktopRuntimeDescriptor {
+  const sharedPackages = [...new Set(sharedNames)].sort().map((name) => {
+    if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`)
+    const path = `node_modules/${name}`
+    const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown
+    if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) {
+      throw new Error(`desktop runtime: invalid shared package manifest ${name}`)
+    }
+    return { name, version: manifest.version, path }
+  })
+  const descriptor: DesktopRuntimeDescriptor = {
+    schemaVersion: 1, release, platform: target.platform, arch: target.arch,
+    sharedPackages, files: inventoryDesktopRuntime(root),
+  }
+  writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`)
+  return descriptor
+}
+
+/**
+ * Read packaged metadata and check shared package records.
+ * @param root - Current application's runtime resources.
+ * @returns Runtime metadata whose release compatibility is verified during packaging.
+ */
+export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor {
+  const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8'))
+  if (!record(value) || typeof value.platform !== 'string' || typeof value.arch !== 'string'
+    || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) {
+    throw new Error('desktop runtime: invalid descriptor')
+  }
+  if (!record(value.release) || typeof value.release.version !== 'string'
+    || typeof value.release.nodeVersion !== 'string' || typeof value.release.pnpmVersion !== 'string') {
+    throw new Error('desktop runtime: invalid release fields')
+  }
+  const release = value.release as unknown as DesktopRelease
+  const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => {
+    if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name)
+      || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) {
+      throw new Error('desktop runtime: invalid shared package record')
+    }
+    return { name: entry.name, version: entry.version, path: entry.path }
+  })
+  if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) {
+    throw new Error('desktop runtime: duplicate shared package')
+  }
+  const files = value.files as DesktopRuntimeFile[]
+  for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) {
+    if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) {
+      throw new Error(`desktop runtime: missing or mismatched ${name}`)
+    }
+  }
+  return { schemaVersion: value.schemaVersion as 1, release, platform: value.platform as NodeJS.Platform,
+    arch: value.arch, sharedPackages, files }
+}
+
+/**
+ * Verify every packaged runtime file against its recorded bytes and permissions at build time.
+ * @param root - Materialized runtime resources.
+ * @param electronVersion - Expected shell version.
+ * @param target - Required execution target; defaults to the current process.
+ * @returns Validated runtime descriptor.
+ */
+export async function verifyDesktopRuntime(
+  root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
+): Promise<DesktopRuntimeDescriptor> {
+  const descriptor = readDesktopRuntime(root)
+  // readDesktopRuntime preserves the disk schema value without validating release compatibility.
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  if (descriptor.schemaVersion !== 1 || descriptor.platform !== target.platform || descriptor.arch !== target.arch) {
+    throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
+  }
+  const release = parseDesktopRelease(descriptor.release)
+  if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
+  for (const entry of descriptor.sharedPackages) {
+    const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
+    if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
+      throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
+    }
+  }
+  const actual = await inventoryRuntimeForVerification(root)
+  // Windows has no portable Unix executable permission bits.
+  const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
+    ? items.map(({ executable: _executable, ...item }) => item) : items
+  if (JSON.stringify(comparable(descriptor.files)) !== JSON.stringify(comparable(actual))) {
+    throw new Error('desktop runtime: integrity verification failed')
+  }
+  return descriptor
+}
+
+/**
+ * Identify exact runtime content independently of its installation path.
+ * @param descriptor - Validated runtime metadata.
+ * @returns SHA-256 runtime identity.
+ */
+export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string {
+  return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex')
+}

+ 0 - 271
apps/desktop/src/seed-store.ts

@@ -1,271 +0,0 @@
-/** Deterministic archive transport for the desktop seed's pnpm store. */
-
-import { createHash } from 'node:crypto'
-import {
-  chmodSync,
-  copyFileSync,
-  cpSync,
-  existsSync,
-  mkdirSync,
-  readdirSync,
-  readFileSync,
-  rmSync,
-  writeFileSync,
-} from 'node:fs'
-import { join, relative, sep } from 'node:path'
-import { DatabaseSync } from 'node:sqlite'
-import { create, extract, list } from 'tar'
-
-/** Directory containing the seed's uncompressed pnpm store archives. */
-export const SEED_STORE_ARCHIVE_DIR = 'store-archives'
-
-/** Manifest describing the deterministic pnpm store archive set. */
-export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json'
-
-const DEFAULT_SHARD_COUNT = 16
-const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u
-const STORE_VERSION_PATTERN = /^v\d+$/u
-
-interface SeedStoreArchiveRecord {
-  readonly file: string
-  readonly entries: number
-}
-
-interface SeedStoreArchiveManifest {
-  readonly schemaVersion: 1
-  readonly shardCount: number
-  readonly archives: readonly SeedStoreArchiveRecord[]
-}
-
-function storeFiles(storeRoot: string): readonly string[] {
-  const files: string[] = []
-  const visit = (directory: string): void => {
-    for (const entry of readdirSync(directory, { withFileTypes: true })) {
-      const path = join(directory, entry.name)
-      if (entry.isSymbolicLink()) {
-        throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`)
-      }
-      if (entry.isDirectory()) {
-        visit(path)
-        continue
-      }
-      if (!entry.isFile()) {
-        throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`)
-      }
-      files.push(relative(storeRoot, path).split(sep).join('/'))
-    }
-  }
-  visit(storeRoot)
-  return files.sort((left, right) => left.localeCompare(right))
-}
-
-function shardFor(path: string, shardCount: number): number {
-  return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount
-}
-
-function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest {
-  const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST)
-  const value = JSON.parse(readFileSync(path, 'utf8')) as unknown
-  if (typeof value !== 'object' || value === null) {
-    throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`)
-  }
-  const candidate = value as Record<string, unknown>
-  if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount)
-    || (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256
-    || !Array.isArray(candidate.archives) || candidate.archives.length === 0) {
-    throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`)
-  }
-  const names = new Set<string>()
-  const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => {
-    if (typeof entry !== 'object' || entry === null) {
-      throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`)
-    }
-    const record = entry as Record<string, unknown>
-    if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file)
-      || names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) {
-      throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`)
-    }
-    const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16)
-    if (shard >= (candidate.shardCount as number)) {
-      throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`)
-    }
-    names.add(record.file)
-    return { file: record.file, entries: record.entries as number }
-  })
-  return {
-    schemaVersion: 1,
-    shardCount: candidate.shardCount as number,
-    archives,
-  }
-}
-
-function assertArchivePath(path: string): void {
-  if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0')
-    || path.split('/').some(part => part === '' || part === '.' || part === '..')) {
-    throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`)
-  }
-}
-
-/**
- * Remove pnpm's registrations for projects that populated the seed store.
- * @param storeRoot - pnpm store directory included in the desktop seed.
- */
-export function removePnpmProjectRegistrations(storeRoot: string): void {
-  for (const entry of readdirSync(storeRoot, { withFileTypes: true })) {
-    if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue
-    rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true })
-  }
-}
-
-function mergeStoreIndex(source: string, destination: string): void {
-  if (!existsSync(destination)) {
-    copyFileSync(source, destination)
-    return
-  }
-  const database = new DatabaseSync(destination)
-  let attached = false
-  try {
-    database.exec('PRAGMA busy_timeout=5000')
-    database.prepare('ATTACH DATABASE ? AS seed').run(source)
-    attached = true
-    database.exec('BEGIN IMMEDIATE')
-    let committed = false
-    try {
-      database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index')
-      database.exec('COMMIT')
-      committed = true
-    } finally {
-      if (!committed) database.exec('ROLLBACK')
-    }
-  } finally {
-    if (attached) database.exec('DETACH DATABASE seed')
-    database.close()
-  }
-}
-
-/**
- * Merge a completely extracted seed store into Desktop's persistent pnpm store.
- * @param source - Verified temporary store extraction.
- * @param destination - Desktop-owned persistent pnpm store.
- */
-export function mergePnpmStore(source: string, destination: string): void {
-  mkdirSync(destination, { recursive: true, mode: 0o700 })
-  const indexPaths = readdirSync(source, { withFileTypes: true })
-    .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name)
-      && existsSync(join(source, entry.name, 'index.db')))
-    .map(entry => `${entry.name}/index.db`)
-  const indexes = new Set(indexPaths)
-  cpSync(source, destination, {
-    recursive: true,
-    force: true,
-    filter: path => !indexes.has(relative(source, path).split(sep).join('/')),
-  })
-  for (const path of indexPaths) {
-    mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/')))
-  }
-}
-
-/**
- * Replace a prepared loose pnpm store with deterministic uncompressed archive shards.
- * @param seedRoot - seed directory that owns the archive output.
- * @param storeRoot - populated pnpm store to archive and remove after success.
- * @param shardCount - stable shard count used to limit update churn.
- */
-export function archivePnpmStore(
-  seedRoot: string,
-  storeRoot: string,
-  shardCount = DEFAULT_SHARD_COUNT,
-): void {
-  if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) {
-    throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`)
-  }
-  const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR)
-  const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST)
-  rmSync(archiveRoot, { recursive: true, force: true })
-  rmSync(manifestPath, { force: true })
-  mkdirSync(archiveRoot, { recursive: true })
-  const shards = Array.from({ length: shardCount }, (): string[] => [])
-  for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path)
-  const archives: SeedStoreArchiveRecord[] = []
-  for (const [index, paths] of shards.entries()) {
-    if (paths.length === 0) continue
-    const file = `store-${index.toString(16).padStart(2, '0')}.tar`
-    create({
-      cwd: storeRoot,
-      file: join(archiveRoot, file),
-      noDirRecurse: true,
-      noMtime: true,
-      portable: true,
-      sync: true,
-    }, paths)
-    chmodSync(join(archiveRoot, file), 0o644)
-    archives.push({ file, entries: paths.length })
-  }
-  if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty')
-  writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`)
-  rmSync(storeRoot, { recursive: true })
-}
-
-/**
- * Validate and extract a packaged pnpm store archive set into an empty directory.
- * @param seedRoot - verified packaged seed directory.
- * @param destination - empty Desktop-owned temporary extraction directory.
- */
-export function extractPnpmStoreArchives(seedRoot: string, destination: string): void {
-  const manifest = readArchiveManifest(seedRoot)
-  const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR)
-  const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => {
-    if (!entry.isFile() || entry.isSymbolicLink()) {
-      throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`)
-    }
-    return entry.name
-  }).sort()
-  const expectedFiles = manifest.archives.map(archive => archive.file).sort()
-  if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
-    throw new Error('desktop seed: pnpm store archive set does not match its manifest')
-  }
-  if (existsSync(destination) && readdirSync(destination).length !== 0) {
-    throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`)
-  }
-  mkdirSync(destination, { recursive: true, mode: 0o700 })
-  const paths = new Set<string>()
-  for (const archive of manifest.archives) {
-    const archivePath = join(archiveRoot, archive.file)
-    const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16)
-    let entries = 0
-    list({
-      file: archivePath,
-      onReadEntry: (entry) => {
-        if (entry.type !== 'File' && entry.type !== 'OldFile') {
-          throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`)
-        }
-        assertArchivePath(entry.path)
-        if (shardFor(entry.path, manifest.shardCount) !== archiveShard) {
-          throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`)
-        }
-        if (paths.has(entry.path)) {
-          throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`)
-        }
-        paths.add(entry.path)
-        entries += 1
-      },
-      strict: true,
-      sync: true,
-    })
-    if (entries !== archive.entries) {
-      throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`)
-    }
-  }
-  for (const archive of manifest.archives) {
-    extract({
-      chmod: true,
-      cwd: destination,
-      file: join(archiveRoot, archive.file),
-      noMtime: true,
-      preservePaths: false,
-      processUmask: 0,
-      strict: true,
-      sync: true,
-    })
-  }
-}

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

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

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

@@ -0,0 +1,13 @@
+/** Serializable Desktop failure diagnostics. */
+
+/**
+ * Preserve nested diagnostics when sending failures to a renderer.
+ * @param error - Startup or runtime failure.
+ * @returns Serializable error state.
+ */
+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 }
+}

+ 1 - 1
apps/desktop/src/update-coordinator.ts

@@ -1,4 +1,4 @@
-/** One Electron release stream for the version-bound shell and dsh seed. */
+/** One Electron release stream for the version-bound shell and bundled dsh runtime. */
 
 import { existsSync } from 'node:fs'
 import { join } from 'node:path'

+ 170 - 0
apps/desktop/tests/backend-controller.spec.ts

@@ -0,0 +1,170 @@
+import { describe, expect, it, vi } from 'vitest'
+import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts'
+
+function deferred() {
+  let resolve!: () => void
+  let reject!: (error: Error) => void
+  const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
+  return { promise, resolve, reject }
+}
+
+function fixture() {
+  const started = deferred()
+  const ready = deferred()
+  const stopping = deferred()
+  const exited = deferred()
+  const states: DesktopBackendState[] = []
+  let fail!: (error: Error) => void
+  const host = {
+    start: vi.fn(() => { started.resolve(); return ready.promise }),
+    stop: vi.fn(() => { stopping.resolve(); return exited.promise }),
+  }
+  const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host })
+  const controller = new DesktopBackendController(create, state => states.push(state))
+  return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } }
+}
+
+describe('desktop backend controller', () => {
+  it('shares preparation and startup between concurrent retries', async () => {
+    const f = fixture()
+    const prepare = vi.fn(async () => {})
+    const first = f.controller.start(prepare)
+    expect(f.controller.start(prepare)).toBe(first)
+    await f.started.promise
+    expect(f.controller.host).toBeUndefined()
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    f.ready.resolve()
+    await first
+    expect(f.controller.host).toBe(f.host)
+    await f.controller.start(prepare)
+    expect(prepare).toHaveBeenCalledTimes(1)
+    expect(f.host.start).toHaveBeenCalledTimes(1)
+    f.exited.resolve()
+    await f.controller.close()
+  })
+
+  it('waits for pending preparation on close and never spawns afterward', async () => {
+    const f = fixture()
+    const preparing = deferred()
+    const prepared = deferred()
+    const start = f.controller.start(() => { preparing.resolve(); return prepared.promise })
+    await preparing.promise
+    let closed = false
+    const close = f.controller.close().then(() => { closed = true })
+    await Promise.resolve()
+    expect(closed).toBe(false)
+    prepared.resolve()
+    await Promise.all([start, close])
+    expect(f.create).not.toHaveBeenCalled()
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    await expect(f.controller.start(async () => {})).rejects.toThrow('closed')
+  })
+
+  it('stops a pending child once and waits for startup and child exit on close', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('stopped')
+    await f.started.promise
+    const close = f.controller.close()
+    expect(f.controller.close()).toBe(close)
+    await f.stopping.promise
+    let closed = false
+    void close.then(() => { closed = true })
+    f.ready.reject(new Error('stopped'))
+    await Promise.resolve()
+    expect(closed).toBe(false)
+    f.exited.resolve()
+    await Promise.all([rejected, close])
+    expect(f.host.stop).toHaveBeenCalledTimes(1)
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    expect(f.controller.host).toBeUndefined()
+  })
+
+  it('publishes preparation errors and permits retry', async () => {
+    const f = fixture()
+    await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile')
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' })
+    expect(f.create).not.toHaveBeenCalled()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    expect(f.controller.state).toEqual({ phase: 'ready' })
+    f.exited.resolve()
+    await f.controller.close()
+  })
+
+  it('cleans up a failed startup before rejecting and displays the actual failure', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('plugin failed')
+    await f.started.promise
+    f.ready.reject(new Error('plugin failed'))
+    await f.stopping.promise
+    f.exited.resolve()
+    await rejected
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' })
+    expect(f.host.stop).toHaveBeenCalledTimes(1)
+    await f.controller.close()
+  })
+
+  it('waits for failed ready child cleanup before preparing a retry', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    f.fail(new Error('transport failed'))
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' })
+    expect(f.controller.host).toBeUndefined()
+    await f.stopping.promise
+    const prepare = vi.fn(async () => {})
+    const retry = f.controller.start(prepare)
+    await Promise.resolve()
+    expect(prepare).not.toHaveBeenCalled()
+    f.exited.resolve()
+    await retry
+    expect(prepare).toHaveBeenCalledTimes(1)
+    expect(f.create).toHaveBeenCalledTimes(2)
+    await f.controller.close()
+  })
+
+  it('does not publish ready when the child fails as readiness settles', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('immediate failure')
+    await f.started.promise
+    f.ready.resolve()
+    f.fail(new Error('immediate failure'))
+    await f.stopping.promise
+    f.exited.resolve()
+    await rejected
+    expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }])
+    await f.controller.close()
+  })
+
+  it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    const stop = f.controller.stop()
+    await f.stopping.promise
+    f.fail(new Error('stopped'))
+    await expect(f.controller.start(async () => {})).rejects.toThrow('stopping')
+    expect(f.controller.state).toEqual({ phase: 'starting' })
+    f.exited.resolve()
+    await stop
+    await f.controller.start(async () => {})
+    expect(f.create).toHaveBeenCalledTimes(2)
+    await f.controller.close()
+  })
+
+  it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    f.fail(new Error('fatal'))
+    await f.stopping.promise
+    f.exited.reject(new Error('child did not exit'))
+    await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
+    await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
+    expect(f.create).toHaveBeenCalledTimes(1)
+    await expect(f.controller.close()).rejects.toThrow('child did not exit')
+  })
+})

+ 3 - 3
apps/desktop/tests/desktop-build-paths.spec.ts

@@ -15,8 +15,8 @@ describe('desktop build paths', () => {
       'artifacts',
       'runtime',
       'packageSet',
-      'seed',
-      'seedPnpm',
+      'dsh',
+      'dshPnpm',
       'nodeExtract',
       'packedDsh',
       'packedVendor',
@@ -27,7 +27,7 @@ describe('desktop build paths', () => {
       expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3)
     }
     expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts'))
-    expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed'))
+    expect(x64.dsh).toContain(join('targets', 'mac-x64', 'dsh'))
     expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
   })
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff