Переглянути джерело

Merge master into fix/parallel-macos-notarization

07akioni 1 місяць тому
батько
коміт
ce94430e16
100 змінених файлів з 981 додано та 312 видалено
  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-08-03-per-session-agent-presets.i18n.yaml
  5. 4 4
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  6. 4 5
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  8. 13 15
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  9. 13 15
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  11. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  12. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml
  14. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
  15. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  17. 5 5
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  18. 5 5
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  20. 6 6
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  21. 6 6
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  23. 2 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  24. 2 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/architecture/2026-09-10-public-package-manifest.i18n.yaml
  29. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
  30. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
  31. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  32. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  33. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  34. 6 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  35. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  36. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  37. 6 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
  38. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
  39. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
  40. 6 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml
  41. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
  42. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md
  43. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  44. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  45. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  46. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  47. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  48. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  49. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  50. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  51. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  52. 2 2
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml
  53. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md
  54. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md
  55. 6 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
  56. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
  57. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
  58. 6 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml
  59. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md
  60. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md
  61. 2 2
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml
  62. 1 1
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md
  63. 1 1
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md
  64. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  65. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  66. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  67. 1 1
      .github/AGENTS.md
  68. 1 1
      .github/review-ownership/README.md
  69. 4 2
      .github/review-ownership/check-approval.mjs
  70. 17 1
      .github/review-ownership/check-approval.test.mjs
  71. 42 2
      .github/workflows/ci-master.yml
  72. 29 12
      .github/workflows/ci.yml
  73. 3 1
      .github/workflows/expected-filenames.yml
  74. 10 1
      .github/workflows/sandbox.yml
  75. 2 2
      README.i18n.yaml
  76. 12 0
      README.md
  77. 12 0
      README.zh.md
  78. 1 0
      apps/cli/tests/web-agent-presets.e2e.ts
  79. 2 2
      apps/desktop/README.i18n.yaml
  80. 12 6
      apps/desktop/README.md
  81. 12 6
      apps/desktop/README.zh.md
  82. 3 1
      apps/desktop/renderer/startup.js
  83. 8 1
      apps/desktop/scripts/prepare-dsh.ts
  84. 67 0
      apps/desktop/scripts/smoke-windows.ps1
  85. 2 2
      apps/desktop/src/backend-controller.ts
  86. 2 5
      apps/desktop/src/ipc.ts
  87. 0 8
      apps/desktop/src/locale.ts
  88. 19 37
      apps/desktop/src/main.ts
  89. 0 3
      apps/desktop/src/preload-app.ts
  90. 5 16
      apps/desktop/src/profile-packages.ts
  91. 23 29
      apps/desktop/src/project-manager.ts
  92. 5 4
      apps/desktop/src/startup-document.ts
  93. 5 23
      apps/desktop/src/startup-error.ts
  94. 4 4
      apps/desktop/tests/backend-controller.spec.ts
  95. 0 8
      apps/desktop/tests/expected/startup-en-plugins.txt
  96. 0 0
      apps/desktop/tests/expected/startup-en-profile.txt
  97. 0 8
      apps/desktop/tests/expected/startup-en-reinstall.txt
  98. 0 3
      apps/desktop/tests/expected/startup-en-restart.txt
  99. 0 8
      apps/desktop/tests/expected/startup-zh-CN-plugins.txt
  100. 0 0
      apps/desktop/tests/expected/startup-zh-CN-profile.txt

+ 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

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 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: 05686acb19aea58d1e3dd42555bff4369529859e
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 8d645d4e39329687c449c1dad81a0bbbb765ebcb
+2026-08-25-electron-desktop-packaging-and-updates.md: 4a4dfe8910905b3c35fbdfdcaedd34a556b532f3
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 69877127578ae4d61643653403b736dbb5e7b1e6

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

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

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

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

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 0c03884bba67dab6e2e38f96ce2e874ed62ba00f
-2026-08-28-subprocess-native-containment.zh.md: b7850e1c4fee06dfeb1a05c968d132de5994686f
+2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84
+2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d

+ 5 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
@@ -54,8 +54,9 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
 - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
+- A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.
@@ -76,6 +77,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 **Recover a failed native launch by replaying the command.** Rejected because an ambiguous failure may occur after target execution and replay can therefore execute the command twice.
 
+**Infer startup failure from an unconsumed request alone.** Rejected because abort, timeout, or disposal can terminate the launcher before request consumption. Matching an observed signal to an owner-issued termination preserves cancellation without hiding an unrelated bootstrap exit or recorded pre-exec failure.
+
 ## Consequences
 
 Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux manager check and scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement.

+ 5 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
@@ -54,8 +54,9 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
+- 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。
@@ -76,6 +77,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 **在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。
 
+**仅从未消费的 request 推断启动失败。** 拒绝,因为 abort、timeout 或 dispose 可能在 request 消费前终止 launcher。将实际观察到的信号与 owner 请求的终止信号匹配,既能保留取消结果,也不会隐藏无关的 bootstrap 退出或已记录的 pre-exec failure。
+
 ## Consequences
 
 受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
-2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892
-2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697
+2026-09-05-package-manifest-types.md: dc018ba019028b942a17cd016c8670f2405c3bc7
+2026-09-05-package-manifest-types.zh.md: 8fd323bfe6058a6a062e3834a8630c0181a203fc

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md

@@ -10,11 +10,11 @@ External packages need Harness manifest types without depending on boot or clien
 
 ## Decision
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished.
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. The [public package metadata decision](2026-09-10-public-package-manifest.md) owns the public field set and the separation from internal tool metadata.
 
 Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local.
 
-App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
+App-boot declares a production dependency because its published declarations reference the shared types. Client modules use a development dependency because their published APIs do not expose these types. Internal image-packer and Session catalog declarations stay with their readers. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
 
 ## Alternatives considered
 
@@ -29,3 +29,5 @@ App-boot declares a production dependency because its published declarations ref
 Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior.
 
 Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added.
+
+Manifest format and host compatibility declarations have no enforcement in current installers or loaders. The type-only package supplies neither a SemVer parser nor an installation policy; its README records that limitation so an author declaration is not mistaken for a compatibility check.

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md

@@ -10,11 +10,11 @@ Status: implemented
 
 ## 决策
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。[公共包元数据决策](2026-09-10-public-package-manifest.zh.md) 拥有公共字段范围及其与内部工具元数据的划分。
 
 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。
 
-App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
+App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块使用开发依赖,因为其发布 API 不暴露这些类型。内部镜像打包器和 Session 目录声明保留在各自读取方。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
 
 ## 考虑过的替代方案
 
@@ -29,3 +29,5 @@ App-boot 声明生产依赖,因为其发布的声明文件引用共享类型
 作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。
 
 编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。
+
+当前安装器和加载器不强制检查 manifest 格式或宿主兼容性声明。纯类型包既不提供 SemVer 解析器,也不提供安装策略;其 README 记录此限制,避免将作者声明误认为兼容性检查。

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

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

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

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

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

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

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
-2026-09-09-desktop-immediate-window-and-direct-start.md: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d
+2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542

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

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

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

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

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: c83bb9826a4068f699e330f911c52064790b08b6
-2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8
+2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385
+2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287

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

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

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

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

+ 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/architecture/2026-09-10-public-package-manifest.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-public-package-manifest.md
+2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44
+2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md

@@ -0,0 +1,35 @@
+# Agent Note: Public package manifest fields
+
+Status: implemented
+
+English | [中文](2026-09-10-public-package-manifest.zh.md)
+
+## Problem
+
+Plugin authors need npm identity, runtime requirements, and DSH declarations from one public import. Internal image-packaging, Session catalog, and generated proxy metadata do not define extension points for community plugins. Exposing those fields together makes internal mechanisms appear available to external authors.
+
+## Decision
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) describes the package.json fields DSH uses, with required `name` and `version`. Its optional `dsh` member uses `DshManifest` for public composition and author metadata. The type is a selected npm field set, not a complete package.json schema. App-boot adapts it with `Partial` for local profiles, which need no published identity.
+
+Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
+
+The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
+
+Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
+
+## Alternatives considered
+
+**Keep internal metadata in the public declaration.** A workspace-only migration catalog and an experimental image packer cannot offer public plugin behavior merely because their metadata is discoverable.
+
+**Put DSH compatibility under `dsh.engines`.** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) places its host requirement in top-level `engines.vscode`. Top-level `engines.dsh` gives authors one location for runtime requirements; DSH still owns enforcement of its custom key.
+
+**Use peer dependencies as the sole host requirement.** Peer dependencies constrain installed npm packages, including the CLI package `@deepseek-ai/dsh`. They do not identify the currently running DSH process when plugins live in a separate profile project.
+
+**Parse every domain through one mandatory parser.** Existing readers consume different subsets and own different errors and defaults. Combining them would make a client reader validate unrelated profile declarations. The public types remain independent of filesystem access and parsing policy.
+
+## Consequences
+
+External authors gain a complete package-level declaration and a smaller DSH author API. Consumers of removed internal types must use their owning implementations. The packer and repository catalog no longer depend on the public declaration package; app-boot retains a production dependency because its published profile type references it.
+
+Compiler and built NodeNext import checks verify required package identity, partial profiles, top-level engine declarations, and the absence of internal fields from the public API. Existing profile, packer, and Session catalog tests retain coverage of their accepted files and malformed declarations. No Session format, plugin loading rule, or model-visible behavior changes.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 公共 package manifest 字段
+
+Status: implemented
+
+[English](2026-09-10-public-package-manifest.md) | 中文
+
+## 问题
+
+插件作者需要从统一的公共导入路径获取 npm 身份、运行时要求和 DSH 声明。内部镜像打包、Session 目录和生成的代理元数据不定义社区插件扩展点。将这些字段一起暴露,会让外部作者误以为内部机制也可供使用。
+
+## 决策
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填。其可选的 `dsh` 成员使用 `DshManifest` 描述公共组合与作者元数据。该类型只选取所需 npm 字段,不是完整的 package.json schema(模式)。App-boot 通过 `Partial` 适配无需发布身份的本地 profile。
+
+运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
+
+镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
+
+各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
+
+## 考虑过的替代方案
+
+**将内部元数据保留在公共声明中。** 仅限工作区的迁移目录和实验性镜像打包器,不会因为其元数据可被发现就提供公共插件行为。
+
+**将 DSH 兼容性放在 `dsh.engines` 下。** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) 将宿主要求放在顶层 `engines.vscode`。顶层 `engines.dsh` 让作者在同一位置声明运行时要求;自定义键的检查仍由 DSH 负责。
+
+**仅用 peer dependency 声明宿主要求。** Peer dependency 约束已安装的 npm 包,包括 CLI 包 `@deepseek-ai/dsh`。插件位于独立 profile 项目时,它们无法标识当前运行的 DSH 进程。
+
+**通过统一的强制解析器解析所有领域。** 现有读取方消费不同字段子集,并各自拥有错误与默认值。合并它们会让客户端读取方校验无关的 profile 声明。公共类型保持独立于文件系统访问和解析策略。
+
+## 后果
+
+外部作者获得完整的包级声明和更小的 DSH 作者 API。已移除内部类型的消费方必须使用各自负责的实现。打包器与仓库目录不再依赖公共声明包;app-boot 保留生产依赖,因为其发布的 profile 类型引用该包。
+
+编译器和构建后的 NodeNext 导入检查验证包身份必填、部分 profile、顶层 engine 声明,以及公共 API 不含内部字段。现有 profile、打包器和 Session 目录测试继续覆盖其接受的文件与畸形声明。Session 格式、插件加载规则和模型可见行为均不改变。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
+2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726
+2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek image-token estimator on the v41 calculator
+
+Status: implemented
+
+English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
+
+## Problem
+
+`deepSeekImageTokens()` in `llm-deepseek` ported the provider's published image-token calculator in its `v4` configuration: a 384×384 scale-up floor, a 384-token cap, an 8:1 width clamp, a grid layout that adds a row for odd row counts and parity corrections, and a pad-to-4 alignment charged at its worst case. The provider's Vision guide now documents a different projection for the current Flash model: images below roughly 544×544 total pixels scale up, larger images scale down to roughly 1300×1300 total pixels, and one image costs at most 1024 tokens. The published calculator carries this as a `v41` configuration and the docs page instantiates that one. The old port underprices an 800×800 request image by 73 tokens, which can delay automatic compaction in sessions containing these images. The error depends on dimensions: a 640×480 image is overestimated by 3 tokens.
+
+## Decision
+
+`image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
+
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+
+## Alternatives considered
+
+**Keep both configurations and select by model id.** The provider states that requests to the retired `deepseek-v4-flash-vision-exp` id are served by the current Flash model, so no reachable route prices under the old configuration. Two configurations would keep dead branches and their tests alive.
+
+**Keep the generic class with the `isNLayout`, pad, and ratio-clamp switches.** A one-configuration port has fewer unreachable branches to exclude from coverage and states the shipped rule directly; a future provider revision changes this one module and its pinned vectors either way.
+
+**Raise the harness pixel budget in the same change.** The provider now accepts roughly 1300×1300 total pixels per image, so the harness's 640,000-pixel projection discards detail the model could use. That is a request-content change with its own snapshot impact, separate from pricing what is actually sent.
+
+## Consequences
+
+An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek 图片 token 估算器改用 v41 计算器
+
+Status: implemented
+
+[English](2026-09-10-deepseek-image-token-calculator-v41.md) | 中文
+
+## 问题
+
+`llm-deepseek` 中的 `deepSeekImageTokens()` 移植的是提供方公开图片 token 计算器的 `v4` 配置:384×384 放大下限、384 token 上限、8:1 宽度钳制、奇数行数额外加一行并做奇偶校正的网格布局,以及按最坏情况计价的 pad-to-4 对齐。提供方的图像理解指南现在为当前 Flash 模型记录了另一套投影规则:总像素小于约 544×544 的图片放大,更大的图片缩小到约 1300×1300 总像素,单张图片最多 1024 token。公开计算器以 `v41` 配置承载这套规则,文档页实例化的也是它。旧移植对一张 800×800 的请求图片低估 73 token,可能使包含这类图片的会话延迟触发自动压缩。误差取决于尺寸:一张 640×480 的图片会被高估 3 token。
+
+## 决策
+
+`image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
+
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。
+
+## 备选方案
+
+**保留两套配置并按模型 id 选择。** 提供方说明发往已下线的 `deepseek-v4-flash-vision-exp` 的请求由当前 Flash 模型承接,所以没有可达路由会按旧配置计价。两套配置会保留死分支及其测试。
+
+**保留带 `isNLayout`、pad 和宽高比钳制开关的通用类。** 单配置移植需要从覆盖率中排除的不可达分支更少,并直接陈述已上线的规则;提供方未来再修订时,两种写法都只改这一个模块和它固定的向量。
+
+**在同一改动中提高 harness 像素预算。** 提供方现在每张图接受约 1300×1300 总像素,harness 的 640,000 像素投影会丢弃模型本可利用的细节。那是请求内容的改动,有自己的快照影响,与为实际发送内容计价是两件事。
+
+## 后果
+
+800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。

+ 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/feature/2026-09-10-composer-reference-previews.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-10-composer-reference-previews.md
+2026-09-10-composer-reference-previews.md: e9425851c1dc4ac0c8dc588d3b234c173eeb9262
+2026-09-10-composer-reference-previews.zh.md: c47718468bf1e38469140c12c1096b7bc5da0f40

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md

@@ -0,0 +1,31 @@
+# Agent Note: Composer reference previews
+
+Status: implemented
+
+English | [中文](2026-09-10-composer-reference-previews.zh.md)
+
+## Problem
+
+Users need to inspect referenced files and skill instructions while composing a message and after sending it. File chips and editable slash tokens have different editing semantics, but both need recognizable preview gestures without changing what the next prompt sends.
+
+## Decision
+
+The [input-trigger source](../../../../packages/client/ui-input-trigger/README.md) owns optional reference activation. The editor routes atomic references by their source identity and editable tokens through the current source lexicon. File and skill sources open the existing right Sidebar file resource in the composing Session. Skill discovery retains the winning provider's optional instruction-file path, avoiding body loads and guesses based on skill names or directory conventions.
+
+The [composer](../../../../packages/client/ui-conversation/README.md) shares reference hover styles while preserving atomic file chips and editable `/name` text. Clicking does not serialize or submit the draft. Invalid chips, selection gestures, and unavailable source targets retain editor handling; virtual skills remain invocable without a file preview.
+
+Sent message bubbles retain their logged skill-invocation evidence for decoration. The [Chat target](../../../../packages/client/ui-chat/README.md) opens file paths in the viewed Session and routes loaded skill names through that Session's source. The shared user-text primitive renders these references as buttons with the existing prose file-link hover and focus style; it leaves session, directory, and command references inert.
+
+## Alternatives considered
+
+**Turning skill tokens into file chips** would change editing, clipboard, and prompt semantics to solve a presentation task. The existing editable token already identifies a skill through its source lexicon.
+
+**Resolving file and skill formats inside the composer** would couple the editor to provider catalog policy and preview services. Source-owned activation keeps those dependencies with the plugins that already own reference discovery.
+
+**Loading each skill body during discovery** would add work and provider side effects before the user requests a preview. Optional path metadata is sufficient for filesystem skills and preserves virtual providers.
+
+## Consequences
+
+Preview paths are transient discovery data, never added to Session messages. The skill plugin invalidates them with its existing per-Session catalog. An uncached click awaits the shared catalog fetch and retains its Session address; invalidation and disposal cancel pending previews. Filesystem providers publish resolved instruction paths while retaining discovered reload locators and resource bases. Sidebar resource readers retain responsibility for current contents, missing-file errors, and access policy. The [workspace source-file decision](2026-09-08-present-workspace-source-files.md) remains the owner of delivered-file behavior; composer previews do not supersede it.
+
+Focused tests cover source routing, disposal, invalidation, quoted paths, selection, and unchanged draft text. The real Web composition exercises both previews, equal hover backgrounds, and deletion after opening; owner-local expected output records the skill document and a sent message. A replayed skill-invocation turn verifies both sent references after reloading history.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 输入框引用预览
+
+Status: implemented
+
+[English](2026-09-10-composer-reference-previews.md) | 中文
+
+## Problem
+
+用户需要在编写消息时和发送后查看引用文件和 skill 指令。文件标签与可编辑的斜杠文本具有不同的编辑语义,但都需要明确的预览手势,且不能改变下一条提示发送的内容。
+
+## Decision
+
+[输入触发来源](../../../../packages/client/ui-input-trigger/README.zh.md)负责可选的引用激活。编辑器按来源身份路由原子引用,按来源当前词表路由可编辑文本。文件和 skill 来源在编写消息的 Session 中打开现有右侧栏文件资源。Skill 发现保留胜出提供方可选的指令文件路径,避免加载正文或根据 skill 名称、目录惯例猜测路径。
+
+[输入框](../../../../packages/client/ui-conversation/README.zh.md)共用引用悬停样式,同时保留原子文件标签和可编辑的 `/name` 文本。点击不序列化或提交草稿。无效标签、选择手势及不可用的来源目标仍由编辑器处理;虚拟 skill 仍可调用,但没有文件预览。
+
+已发送消息的气泡保留日志中的 skill 调用证据作为装饰依据。[Chat 目标](../../../../packages/client/ui-chat/README.zh.md)在当前查看的 Session 中打开文件路径,并通过该 Session 的来源路由已加载的 skill 名称。共享用户文本组件将这些引用渲染为按钮,复用现有正文文件链接的悬停和聚焦样式;会话、目录和命令引用不提供导航。
+
+## Alternatives considered
+
+**将 skill 文本转换为文件标签**会为了展示需求而改变编辑、剪贴板和提示语义。现有可编辑文本已经能够通过来源词表标识 skill。
+
+**在输入框内解析文件和 skill 格式**会让编辑器依赖提供方目录策略及预览服务。由来源负责激活,使这些依赖留在已经负责引用发现的插件中。
+
+**发现时加载每个 skill 的正文**会在用户请求预览之前增加工作和提供方副作用。可选路径元数据足以支持文件系统 skill,并保留虚拟提供方。
+
+## Consequences
+
+预览路径是临时发现数据,不会加入 Session 消息。Skill 插件使用现有的按 Session 缓存机制使路径失效。缓存未就绪时,点击等待共享目录请求并保留所属 Session 地址;缓存失效和插件释放会取消待处理的预览。文件系统提供方公布解析后的指令路径,同时保留发现时的重新加载定位信息和资源根。侧栏资源读取器继续负责当前内容、文件缺失错误和访问策略。[工作区源文件决策](2026-09-08-present-workspace-source-files.zh.md)仍负责交付文件行为;输入框预览不取代该决策。
+
+针对性测试覆盖来源路由、释放、缓存失效、带引号路径、文本选择和草稿不变。真实 Web 组合验证两种预览、一致的悬停背景及打开后的删除操作;其所属的预期输出记录 skill 文档及已发送消息。回放的 skill 调用轮次验证刷新历史后两种已发送引用仍可预览。

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md
-2026-07-21-serial-cross-platform-ci-reference.md: edb81b643d0cef2e5bc807005a9016324b8430ab
-2026-07-21-serial-cross-platform-ci-reference.zh.md: 41fd9c032038f2a312978acf995febfdab34aeaa
+2026-07-21-serial-cross-platform-ci-reference.md: 24022fea271d677a4588bd5dc9c7cb5b417ca8b7
+2026-07-21-serial-cross-platform-ci-reference.zh.md: c9fbc84d8ff91803acc6bcd008607fc03136b832

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md

@@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel
 
 Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
-The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
+The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation, the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under the `selfhosted` failover value, and Blacksmith's Windows runners under the `blacksmith` value (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md

@@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
-当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
+当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 故障切换取值下使用自托管 `[self-hosted, dsh-win-ci, windows]` 池,在 `blacksmith` 取值下使用 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md);另见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: 559fa61bcf416bed3bd58b3ffcbc145038cdce22
-2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e
+2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b
+2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md

@@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
 
 ## Decision
 
-The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
+The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
 
 The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness.
 
@@ -32,9 +32,9 @@ The two switches are independent: flip only the one whose platform is degraded.
 
 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
-3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
+3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
 
-**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
+**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 **Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members.
 
@@ -45,11 +45,11 @@ Capacity includes the master standby, main-CI jobs, and three release-rehearsal
 
 ### Switch back
 
-Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to anything other than `selfhosted`). New runs resolve back to their hosted pools. Remove any extra instances that were registered during the incident.
+Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to any value other than `selfhosted` or `blacksmith`). New runs resolve back to their hosted pools. Setting it to `blacksmith` keeps the jobs on Blacksmith until the value changes. Remove any extra instances that were registered during the incident.
 
 ### Trust boundary
 
-The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
+The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the `selfhosted` legs; the `blacksmith` legs carry no exclusion), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
 
 ## Alternatives considered
 

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
+三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
 
 [被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
 
@@ -32,9 +32,9 @@ Status: implemented
 
 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。
 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
-3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
+3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
 
-**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
+**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。
 
@@ -45,11 +45,11 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以
 
 ### 切回
 
-删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回各自的托管池。若故障期间追加注册过实例,将其移除。
+删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 与 `blacksmith` 之外的任何值),新的运行即解析回各自的托管池。设为 `blacksmith` 会让作业留在 Blacksmith,直到该值改变。若故障期间追加注册过实例,将其移除。
 
 ### 信任边界
 
-这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
+这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、Dependabot 由 `selfhosted` 腿排除;`blacksmith` 腿不带排除),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0
-2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe
+2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
+2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md

@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
 
 The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
 
-Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
+Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
 
 `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md

@@ -14,7 +14,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
 
 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
 
-每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
+每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
 
 `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
 

+ 2 - 2
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.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/process/2026-09-06-node-compatibility-selfhosted.md
-2026-09-06-node-compatibility-selfhosted.md: c78092834123b837d100814be9beba52c1a41397
-2026-09-06-node-compatibility-selfhosted.zh.md: 6dcff8aa197c0995e4e90d2d56179340a41bc783
+2026-09-06-node-compatibility-selfhosted.md: c361d21d3e1093dd5c87bf2ba085bdd1acacb5d8
+2026-09-06-node-compatibility-selfhosted.zh.md: 80a9d8519d6084f5e01944b277882a51b2291e94

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md

@@ -10,7 +10,7 @@ The Node 22.19, 24.9, and 26 compatibility jobs consume hosted Linux minutes eve
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration.
+[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. The `blacksmith` failover value's branch drops those predicates: it targets ephemeral Blacksmith runners, so repository identity and fork status do not gate it (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 The temporary tool cache trades repeated Node downloads for isolation across concurrent runners and Node versions. A setup-node-only [ESM preload](../../../../scripts/ci-compatible-toolcache.mjs) assigns the cache inside the action process: the Actions runner overwrites reserved environment variables after reading step configuration. An executed path check rejects installations outside runner temp; compatibility processes do not inherit the preload. pnpm keeps its existing private setup destination and persistent content-addressed store. Compile caches and node-gyp headers use runner temp before the first pnpm invocation. No global Node symlink or system package changes are introduced. Hosted jobs retain their tool and package caching; self-hosted jobs do not restore or upload hosted package caches. The runner owns temporary-directory cleanup between jobs, and the shared image supplies native npm packages’ compiler and Python prerequisites.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。
+[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。`blacksmith` 故障切换取值下的分支放弃这些条件:它面向临时的 Blacksmith 运行器,因此仓库身份与 fork 状态不参与门控(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 临时工具缓存以重复下载 Node 为代价,换取并发运行器与 Node 版本之间的隔离。仅用于 setup-node 的 [ESM 预加载模块](../../../../scripts/ci-compatible-toolcache.mjs) 在 action 进程内指定缓存:Actions 运行器在读取步骤配置后会覆盖保留的环境变量。实际执行的路径检查拒绝运行器临时目录之外的安装;兼容性进程不继承预加载设置。pnpm 保留现有的私有安装目录和持久化内容寻址 store。编译缓存与 node-gyp 头文件在首次调用 pnpm 前就使用运行器临时目录。不引入全局 Node 符号链接或系统软件包变更。托管作业保留其工具与软件包缓存;自托管作业不恢复或上传托管软件包缓存。运行器负责作业之间的临时目录清理,共享镜像提供原生 npm 软件包所需的编译器和 Python 前置依赖。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.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-09-blacksmith-failover-leg.md
+2026-09-09-blacksmith-failover-leg.md: eaaa6f4d5c8326ae7686dc9f485143781d065313
+2026-09-09-blacksmith-failover-leg.zh.md: 562aed96327a86c24e38b502f2ef4756a11b91e1

Різницю між файлами не показано, бо вона завелика
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md


Різницю між файлами не показано, бо вона завелика
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md


+ 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) 继续负责待定与成功状态的语义。

+ 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 时不采用。独立轮询期限仍会与执行通道的预算竞争。

+ 1 - 1
.github/AGENTS.md

@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches and Dependabot hosted fallback ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md)).
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).

+ 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 () => ({

+ 42 - 2
.github/workflows/ci-master.yml

@@ -281,7 +281,15 @@ jobs:
   # The named pools are restricted at the organization level to this repository.
   larger-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -291,50 +299,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workload: typecheck
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workload: typecheck
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workload: production-site
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workload: production-site
     steps:
       - uses: actions/checkout@v6
@@ -372,7 +392,15 @@ jobs:
   # Windows runs both blocking build targets concurrently through run-gates.
   consolidated-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -382,50 +410,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workers: '4'
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workers: '8'
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workers: '16'
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workers: '32'
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workers: '32'
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workers: '32'
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workers: '2'
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workers: '2'
     steps:
       - uses: actions/checkout@v6

+ 29 - 12
.github/workflows/ci.yml

@@ -31,7 +31,10 @@ jobs:
   # repository state — not PR-editable, no merge required) retargets all
   # three onto the in-house
   # vm-backup pool and re-running the failed jobs is the entire switch —
-  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
+  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md.
+  # Setting the variable to 'blacksmith' instead routes the same jobs onto
+  # Blacksmith's hosted runners at the matching vCPU size (see
+  # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The
   # in-house pool's readiness is re-proven on every master push by the
   # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
   # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
@@ -39,7 +42,8 @@ jobs:
   node-24:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -105,7 +109,8 @@ jobs:
   node-24-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -226,7 +231,8 @@ jobs:
   node-24-consumers:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -327,9 +333,12 @@ jobs:
 
   node-compat:
     if: github.event_name == 'pull_request'
-    # This job admits only repository-owned PR code to the persistent shared VM.
+    # Under the selfhosted leg this job admits only repository-owned PR code
+    # to the persistent shared VM; the blacksmith branch targets ephemeral
+    # runners and carries none of those predicates.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.head.repo.full_name == github.repository
           && github.event.pull_request.head.repo.fork == false
           && github.event.pull_request.user.login != 'dependabot[bot]'
@@ -451,7 +460,8 @@ jobs:
   windows-build:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -495,7 +505,8 @@ jobs:
   windows-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -571,7 +582,8 @@ jobs:
   windows-native-tests:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -620,7 +632,8 @@ jobs:
     if: github.event_name == 'pull_request'
     continue-on-error: true
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -675,9 +688,13 @@ jobs:
     # the worker jobs it aggregates, so a standard-hosted outage cannot strand
     # the branch-protection verdict either. It retargets with the Linux switch
     # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the
-    # required Linux workers and runs on the vm-backup pool.
+    # required Linux workers and runs on the vm-backup pool. Under the
+    # 'blacksmith' value the verdict shares Blacksmith's pool with its workers
+    # through the same selector, so a Blacksmith outage strands both together —
+    # the accepted consequence of the explicit opt-in switch.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'ubuntu-latest' }}

+ 3 - 1
.github/workflows/expected-filenames.yml

@@ -18,7 +18,9 @@ env:
 jobs:
   expected-filenames:
     name: no golden filenames
-    runs-on: ubuntu-latest
+    runs-on: >-
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || 'ubuntu-latest' }}
     steps:
       - uses: actions/checkout@v6
 

+ 10 - 1
.github/workflows/sandbox.yml

@@ -44,6 +44,12 @@ jobs:
       fail-fast: false
       matrix:
         include:
+          # Only the bwrap leg has a Blacksmith equivalent: the earlier
+          # migration dispatch runs measured that Blacksmith's Linux images do
+          # not enforce Landlock (the run-guard would turn the leg red) and
+          # its macOS image is unverified, so those legs stay on GitHub's
+          # images under every failover value — see
+          # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md.
           - os: ubuntu-latest
             runner: bwrap
           - os: ubuntu-24.04
@@ -53,7 +59,10 @@ jobs:
           - os: macos-latest
             runner: seatbelt
     name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }})
-    runs-on: ${{ matrix.os }}
+    runs-on: >-
+      ${{ matrix.runner == 'bwrap' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+          && 'blacksmith-4vcpu-ubuntu-2404'
+          || matrix.os }}
     timeout-minutes: 20
     steps:
       - uses: actions/checkout@v6

+ 2 - 2
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 README.md
-README.md: 9f89db3d4502dea4a0d181799164f304d4976740
-README.zh.md: aa66ef1d24a5e2165859e9337273d807dff3d070
+README.md: 36adfe913902e75ce0673adc2cb5cb692151a291
+README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6

+ 12 - 0
README.md

@@ -56,6 +56,18 @@ Start with the [development guide](docs/development.md) and [architecture docume
 
 For agents, follow [AGENTS.md](AGENTS.md).
 
+## Citation
+
+```bibtex
+@misc{deepseek-harness2026,
+  title={DeepSeek Harness: Everything is a Plugin},
+  author={DeepSeek-AI},
+  year={2026},
+  publisher={GitHub},
+  howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
+}
+```
+
 ## License
 
 [MIT](LICENSE)

+ 12 - 0
README.zh.md

@@ -77,6 +77,18 @@ pnpm dsh web
 
 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。
 
+## 引用
+
+```bibtex
+@misc{deepseek-harness2026,
+  title={DeepSeek Harness: Everything is a Plugin},
+  author={DeepSeek-AI},
+  year={2026},
+  publisher={GitHub},
+  howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
+}
+```
+
 ## 许可证
 
 [MIT](LICENSE)

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

+ 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: cb6fbde2e0ba6424280148349655a05c3ffa8549
-README.zh.md: 868644986be1817d1ed32fce05f72fc67d844e34
+README.md: cd5425fab72ae14eaf58ad2ff4f39472cc510ab1
+README.zh.md: 32e15d8f779282e230240610578085a900343790

+ 12 - 6
apps/desktop/README.md

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

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

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

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

@@ -19,7 +19,9 @@ async function main() {
     document.querySelector('#error').textContent = failed ? state.message : ''
     document.querySelector('#actions').hidden = !failed
     for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
-    document.querySelector('#reset-advice').hidden = !failed
+    for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) {
+      document.querySelector(selector).hidden = !failed || !state.profileRecovery
+    }
     document.querySelector('#reinstall-advice').hidden = !failed
   }
   let changed = false

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -9,17 +9,14 @@ const startup: DshDesktopStartupApi = {
   locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
   backend: {
     status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['backend']['status']>,
-    retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
     subscribe(listener) {
       const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
       ipcRenderer.on(DESKTOP_IPC.backendState, handle)
       return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
     },
   },
-  openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise<void>,
   disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
   restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
-  close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise<void>,
   resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
 }
 

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


Деякі файли не було показано, через те що забагато файлів було змінено