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

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

creatixchu 1 неделя назад
Родитель
Сommit
275af6a2e2
100 измененных файлов с 1066 добавлено и 118 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml
  11. 51 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md
  12. 51 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml
  14. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md
  15. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
  17. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
  18. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
  20. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
  21. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
  22. 6 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml
  23. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md
  24. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md
  25. 6 0
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
  26. 37 0
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
  27. 37 0
      .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
  28. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml
  29. 1 1
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md
  30. 1 1
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md
  31. 6 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml
  32. 41 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
  33. 41 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md
  34. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
  35. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
  36. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
  37. 8 0
      .github/workflows/ci.yml
  38. 13 0
      .oxlintrc.json
  39. 1 1
      apps/cli/package.json
  40. 1 1
      apps/desktop-host/package.json
  41. 2 2
      apps/desktop/README.i18n.yaml
  42. 3 1
      apps/desktop/README.md
  43. 3 1
      apps/desktop/README.zh.md
  44. 1 1
      apps/desktop/package.json
  45. 122 0
      apps/desktop/scripts/package-macos.ts
  46. 23 1
      apps/desktop/scripts/package-target.ts
  47. 7 0
      apps/desktop/scripts/verify-macos-signature.d.mts
  48. 12 0
      apps/desktop/scripts/verify-macos-signature.mjs
  49. 45 0
      apps/desktop/tests/macos-notarized-application.spec.ts
  50. 190 0
      apps/desktop/tests/package-macos.spec.ts
  51. 1 1
      apps/web/package.json
  52. 9 4
      apps/web/tests/feedback-release.e2e.ts
  53. 25 14
      apps/web/tests/message-feedback.e2e.ts
  54. 67 0
      apps/web/tests/present.e2e.ts
  55. 31 0
      apps/web/tests/produced-files.e2e.ts
  56. 2 2
      docs/config-catalog.i18n.yaml
  57. 1 1
      docs/config-catalog.md
  58. 1 1
      docs/config-catalog.zh.md
  59. 2 2
      docs/subsystems/feedback.i18n.yaml
  60. 3 3
      docs/subsystems/feedback.md
  61. 3 3
      docs/subsystems/feedback.zh.md
  62. 2 2
      docs/subsystems/session.i18n.yaml
  63. 6 0
      docs/subsystems/session.md
  64. 6 0
      docs/subsystems/session.zh.md
  65. 1 1
      package.json
  66. 1 1
      packages/acp/acp/package.json
  67. 1 1
      packages/api/gateway/package.json
  68. 1 1
      packages/api/remotes/package.json
  69. 1 1
      packages/api/session-controller/package.json
  70. 2 0
      packages/api/session-controller/src/commands.ts
  71. 1 0
      packages/api/session-controller/src/history.ts
  72. 1 0
      packages/api/session-controller/src/index.ts
  73. 1 1
      packages/api/settings-controller/package.json
  74. 1 1
      packages/api/workspace-controller/package.json
  75. 1 1
      packages/api/workspace-files/package.json
  76. 1 1
      packages/attachment/attachment-local/package.json
  77. 1 1
      packages/attachment/attachment/package.json
  78. 1 1
      packages/boot/app-boot/package.json
  79. 1 1
      packages/boot/cmdline/package.json
  80. 1 1
      packages/bundle/acp-app/package.json
  81. 1 1
      packages/bundle/base/package.json
  82. 1 1
      packages/bundle/headless/package.json
  83. 1 0
      packages/bundle/headless/src/index.ts
  84. 1 1
      packages/bundle/sdk-app/package.json
  85. 1 1
      packages/bundle/sdk-minimal/package.json
  86. 1 1
      packages/bundle/web-app/package.json
  87. 2 2
      packages/client/README.i18n.yaml
  88. 1 1
      packages/client/README.md
  89. 1 1
      packages/client/README.zh.md
  90. 1 1
      packages/client/connection/package.json
  91. 1 1
      packages/client/file-upload/package.json
  92. 1 1
      packages/client/hmr/package.json
  93. 1 1
      packages/client/locale/package.json
  94. 1 1
      packages/client/modules/package.json
  95. 1 1
      packages/client/resources/package.json
  96. 1 1
      packages/client/store/package.json
  97. 1 1
      packages/client/ui-agent-preset/package.json
  98. 1 1
      packages/client/ui-approval/package.json
  99. 1 1
      packages/client/ui-attachment/package.json
  100. 1 1
      packages/client/ui-brand-official/package.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-06-11-dev-invariants-over-deep-readonly.md
-2026-06-11-dev-invariants-over-deep-readonly.md: 9adb741db1e26e3a9f750fed60524351159cfeaa
-2026-06-11-dev-invariants-over-deep-readonly.zh.md: d352eca985b6594ec5d10c9b73c2ecc18ea458b5
+2026-06-11-dev-invariants-over-deep-readonly.md: 0784960397f4d198e136e284ef9232b4ed3e2c2c
+2026-06-11-dev-invariants-over-deep-readonly.zh.md: 7183813130560bf82426c8719570b668ba86b7e5

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md

@@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel
 
 `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
 
-The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
+The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` reads the current length without materializing an array. Synchronous historical readers are deprecated under the [event-read policy](2026-09-09-deprecate-synchronous-session-event-reads.md). Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
 
 This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
 
@@ -53,7 +53,7 @@ Detaching `deriveMessages()` would protect the most common request path but leav
 ## Consequences
 
 - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
-- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array.
+- Existing `snapshotEvents()` and `eventAt()` callers retain immutable read results while their migration is deferred; `seq` reads the log length without copying the array.
 - Request-side mutation cannot reach stored history through derived messages.
 - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
 - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md

@@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 
 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
 
-被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。
+被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。`seq` 无需物化数组即可读取当前长度。同步历史读取方法按[事件读取策略](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)弃用。种子记录在构造成功前经过相同的验证、快照与冻结边界。
 
 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
 
@@ -53,7 +53,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 ## 后果
 
 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
-- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径
+- 现有 `snapshotEvents()` 和 `eventAt()` 调用方在暂缓迁移期间仍获得不可变的读取结果;`seq` 无需复制数组即可读取日志长度
 - 请求侧的修改无法通过派生消息触及已存储的历史。
 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。

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

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

@@ -83,7 +83,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
 
 Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation.
 
-Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
 
 The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
 

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

@@ -83,7 +83,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。
 
-Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 

+ 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: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84
-2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d
+2026-08-28-subprocess-native-containment.md: 7523f9d66e7a302f6ce9c77d93671a5b1303a5aa
+2026-08-28-subprocess-native-containment.zh.md: 252a8e9fd058cf37a1a7a70f09394a6811d58580

+ 2 - 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 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.
+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 `LoadState`, `ActiveState`, and `TasksCurrent`: 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, except that once termination was requested and the launcher has exited, a still-active unit reporting no processes is an empty range: the manager ends a scope only on the populated-to-empty transition, so a payload killed before it entered the cgroup never triggers one. That conclusion stops the leftover unit so transient units cannot accumulate, while an unreported or `[not set]` process count stays unknown and keeps waiting. 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,7 +54,7 @@ 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 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.
+- 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`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `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.

+ 2 - 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,除非实际观察到的信号匹配 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 的私有路径。
+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` 与 `TasksCurrent`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态;例外是:一旦请求过终止且 launcher 已经退出,报告没有任何进程却仍 active 的 unit 就是空 range——manager 只在观测到 populated→empty 转变时才结束 scope,因此进入 cgroup 前就被杀死的 payload 永远不会触发该转变。该判定会 stop 掉这个遗留 unit,使 transient unit 不会累积;未上报或为 `[not set]` 的进程数仍视为未知并继续等待。未知或 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,7 +54,7 @@ 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 建立状态(包括 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`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`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 语义。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md
+2026-09-09-deprecate-synchronous-session-event-reads.md: a2a86b0d6500269531ca1088738aaa612076bfb4
+2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 38468708a5323b6ebf94377f3cc8f2c86dc95dde

+ 51 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md

@@ -0,0 +1,51 @@
+# Agent Note: Deprecate synchronous reads of arbitrary Session events
+
+Status: implemented
+
+English | [中文](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)
+
+## Problem
+
+Synchronous access to arbitrary event positions makes consumers depend on the complete Session event sequence being immediately available in memory. The storage direction is to stop retaining that complete sequence in memory. Once historical events require storage I/O, the runtime cannot preserve the same synchronous read guarantee without retaining the history or blocking on storage.
+
+New callers increase that dependency even when they read only one old event. Repeated history scans after resume also make ordinary domain logic depend on historical storage instead of the state it actually needs.
+
+## Decision
+
+All operations that synchronously read arbitrary positions or ranges of Session event history are deprecated, including `Session.eventAt()`, `Session.snapshotEvents()`, and `Session.ownEvents()`. Existing logic may remain unmigrated for now, but new calls are prohibited. New aliases or wrappers that expose the same synchronous historical access are prohibited as well.
+
+The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory.
+
+Repository test files, including `scripts/**/*.spec.{ts,tsx}`, may call these three readers to inspect emitted events and exercise Session history behavior. The test-file lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or non-test repository scripts.
+
+### State needed after resume
+
+Design durable event fields and Session projections together so each domain can reconstruct the state its consumers need. Restore that state during resume, then maintain it incrementally from newly committed events. After resume, ordinary logic reads the projection or processes the delivered current event instead of looking back through historical events. Reading already-maintained projection state synchronously does not require arbitrary access to the event log.
+
+Historical content presented on demand uses explicit asynchronous pagination and progressive loading, with each read limited to the requested window. Loading the complete sequence behind a synchronous helper preserves the dependency this decision removes.
+
+### Operations that require complete history
+
+Fork and a small number of operations may genuinely need a complete historical sequence or inherited prefix. Their need for those records remains valid and requires an explicit storage read. It does not require the whole sequence to remain resident or grant an exception for new calls to deprecated synchronous readers. Each such consumer must establish why its result requires the complete sequence rather than projected state or a limited historical window.
+
+## Alternatives considered
+
+**Keep synchronous single-event reads while deprecating only full snapshots.** A single requested event may also be absent from memory. Restricting the result size does not remove the storage dependency, and helpers such as `ownEvents()` retain the same assumption for a suffix.
+
+**Keep the complete sequence resident to preserve the read APIs.** This lets consumer convenience dictate Session memory retention and prevents the intended storage design. Projections preserve required state, while explicit historical reads preserve access to the records themselves.
+
+**Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change.
+
+**Disable deprecation lint throughout test files or exempt production readers by name.** Tests only need the three reader names; other deprecated APIs must remain errors. Production code retains the prohibition on new synchronous reads.
+
+**Add a separate test-only reader API.** The three existing readers already expose the observations these tests need. Wrapping them adds an API and production-import checks without changing those observations.
+
+## Consequences
+
+New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log.
+
+Calls outside test files carry line-scoped `typescript/no-deprecated` waivers that identify deferred migration or delegation between deprecated readers. Remove a waiver when its deprecated call is removed; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records.
+
+## Related decisions
+
+The [session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md) continues to own event ownership and freezing; this decision supersedes its recommendation to use synchronous historical readers. The [required projection reader decision](2026-08-19-session-projection-mandatory-seam.md) continues to own missing-state failures and typed state reads. The [recallable compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) retains its recall design while replacing its proposed synchronous history access with paged reads.

+ 51 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 弃用对会话任意位置事件的同步读取
+
+Status: implemented
+
+[English](2026-09-09-deprecate-synchronous-session-event-reads.md) | 中文
+
+## 问题
+
+同步访问任意事件位置,使消费方依赖完整会话事件序列在内存中随时可用。存储的演进方向是不再将完整序列保存在内存中。历史事件一旦需要存储 I/O,运行时若不继续保留历史或阻塞等待存储,就无法维持相同的同步读取保证。
+
+即使只读取一个旧事件,新增调用也会加深这种依赖。恢复后反复扫描历史,还会使普通领域逻辑依赖历史存储,而不是它真正需要的状态。
+
+## 决策
+
+弃用所有同步读取会话事件历史任意位置或区间的操作,包括 `Session.eventAt()`、`Session.snapshotEvents()` 和 `Session.ownEvents()`。现有逻辑可以暂不迁移,但禁止新增调用。同样禁止新增暴露相同同步历史访问能力的别名或包装层。
+
+这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。
+
+仓库测试文件(包括 `scripts/**/*.spec.{ts,tsx}`)可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。测试文件 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或非测试仓库脚本。
+
+### 恢复后需要的状态
+
+结合设计持久事件字段与会话投影,使每个领域都能重建其消费方所需的状态。在恢复期间还原这些状态,随后通过新提交的事件增量维护。恢复后,普通逻辑读取投影或处理当前收到的事件,而不是回头查找历史事件。同步读取已经维护好的投影状态,不需要任意访问事件日志。
+
+按需展示的历史内容采用显式异步分页和渐进式加载,每次读取限制在请求的窗口内。在同步辅助方法背后加载完整序列,仍保留了本决策要消除的依赖。
+
+### 确实需要完整历史的操作
+
+fork 等少数操作可能确实需要完整历史序列或继承前缀。它们对这些记录的需求仍然成立,需要通过显式存储读取来满足。这不要求完整序列常驻内存,也不构成新增已弃用同步读取调用的例外。每个此类消费方都必须说明,为什么其结果需要完整序列,而不能只使用投影状态或局部历史窗口。
+
+## 曾考虑的替代方案
+
+**只弃用完整快照,保留同步单事件读取。** 单个被请求的事件也可能不在内存中。限制返回结果大小并不能消除存储依赖,`ownEvents()` 等辅助方法对后缀序列也保留着相同假设。
+
+**为保留读取 API 而让完整序列常驻内存。** 这会让消费方的便利性决定会话的内存保留方式,阻碍预期的存储设计。投影保留所需状态,显式历史读取则保留对记录本身的访问能力。
+
+**要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。
+
+**在整个测试文件中禁用弃用 lint,或按名称豁免生产代码中的读取方法。** 测试只需要这三个读取方法名;其他弃用 API 必须继续报错。生产代码仍禁止新增同步读取。
+
+**添加单独的测试专用读取 API。** 三个现有读取方法已经提供这些测试所需的观察结果。包装它们会增加一个 API 及生产代码导入检查,却不会改变这些观察结果。
+
+## 后果
+
+新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。
+
+测试文件之外的调用带有逐行的 `typescript/no-deprecated` 豁免,说明暂缓迁移或已弃用读取方法之间的委托。删除已弃用调用时应一并删除其豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。
+
+## 相关决策
+
+[会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)继续负责事件所有权与冻结;本决策取代其中对同步历史读取方法的使用建议。[投影读取必需性决策](2026-08-19-session-projection-mandatory-seam.zh.md)继续负责缺失状态时的失败规则与类型化状态读取。[可回溯压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.zh.md)保留其回溯设计,并将其中拟议的同步历史访问替换为分页读取。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.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-built-bundle-css-exemption.md
+2026-09-10-built-bundle-css-exemption.md: e5a55689696f885b8ea9411a5b3a7598295fff35
+2026-09-10-built-bundle-css-exemption.zh.md: 1c1aab35df72497946b02bf2a7f67a2271c804e5

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md

@@ -0,0 +1,27 @@
+# Agent Note: Built-bundle exemption for a failing stylesheet
+
+Status: implemented
+
+English | [中文](2026-09-10-built-bundle-css-exemption.zh.md)
+
+## Problem
+
+The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) exempts the Dockkit bundle because Node cannot load its stylesheets, and admitted one exact stylesheet path as the evidence: `packages/client/ui-dockkit/lib/components/dockkit.module.css`. The built bundle imports the workspace package `@deepseek-ai/dsh-client-ui-primitives` before its own stylesheet, and the `tsx` launcher resolves that specifier through tsconfig `paths` into the dependency's `src` tree, so the sweep reports `ERR_UNKNOWN_FILE_EXTENSION` for `packages/client/ui-primitives/src/StateDot.module.css`. The pinned path cannot match on a tree with client build output, and the Windows complete-gate inventory reported the exempt bundle as an unexpected baseline failure.
+
+## Decision
+
+The Dockkit exemption admits Node's unknown-`.css`-extension refusal for any stylesheet. Another extension, another error code, and an unrelated error message stay findings, as does an exempt bundle that imports cleanly.
+
+## Alternatives considered
+
+**Admit the dependency's source stylesheet alongside the pinned one.** That file is what the sweep reports, but the bundle's import order and the launcher's path mapping select it. Pinning it would certify those two details instead of the `.css` exemption.
+
+**Classify every CSS exemption by the same rule.** The Dockkit pin is the recorded evidence this change corrects; the other two stylesheet exemptions were never classified, and tightening them would change what they admit beyond the reported defect.
+
+**Drop the classification and admit any failure.** A bundle that stopped importing for an unrelated reason would then hide inside the exemption total.
+
+## Consequences
+
+The sweep reports the Dockkit bundle when it stops importing for any reason other than Node's unknown-`.css`-extension refusal, and the entry no longer asserts which stylesheet fails. [Scoped resolve/load hooks](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts) exercise an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption without modifying shared build artifacts.
+
+The [CI observation decision](../testing/2026-09-08-ci-completion-observations.md) keeps the fixture completion and isolation decisions it owns; its built-client classification paragraph keeps the sweep summary and links here for the admitted evidence.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 构建产物豁免以失败的样式表为准
+
+Status: implemented
+
+[English](2026-09-10-built-bundle-css-exemption.md) | 中文
+
+## 问题
+
+[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)因 Node 无法加载 Dockkit bundle 的样式表而豁免该 bundle,并曾以一个精确的样式表路径作为接受证据:`packages/client/ui-dockkit/lib/components/dockkit.module.css`。该已构建 bundle 在导入自身样式表之前先导入 workspace 包 `@deepseek-ai/dsh-client-ui-primitives`,`tsx` 启动器又通过 tsconfig `paths` 把这个说明符解析进依赖的 `src` 树,因此 sweep 报告的是 `packages/client/ui-primitives/src/StateDot.module.css` 的 `ERR_UNKNOWN_FILE_EXTENSION`。固定路径在带有 client 构建输出的树上无法匹配,Windows 完整门禁的清单于是把这个豁免 bundle 报告为意外的基线失败。
+
+## 决策
+
+Dockkit 豁免接受 Node 对任意样式表因未知 `.css` 扩展名而拒绝加载。其他扩展名、其他错误码和无关的错误消息仍计为发现,能顺利完成导入的豁免 bundle 同样计为发现。
+
+## 考虑过的替代方案
+
+**在固定路径之外同时接受依赖的源样式表。** sweep 报告的正是该文件,但选中它的是 bundle 的导入顺序和启动器的路径映射。固定该路径认定的将是这两处细节,而非 `.css` 豁免。
+
+**用同一规则分类每个 CSS 豁免。** Dockkit 固定路径是本次修改所纠正的已记录证据;另外两个样式表豁免从未被分类,收窄它们会在所报告缺陷之外改变其接受的内容。
+
+**放弃分类,接受任何失败。** 届时因无关原因停止导入的 bundle 会隐藏在豁免总数之内。
+
+## 后果
+
+只要 Dockkit bundle 因 Node 未知 `.css` 扩展名拒绝之外的任何原因停止导入,sweep 就会报告它,该条目也不再断言失败的是哪个样式表。[限定范围的 resolve/load hook](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts)覆盖被接受的 Dockkit 样式表、依赖的源样式表、其他扩展名、任意消息、其他错误码和陈旧豁免,不修改共享构建产物。
+
+[CI 观察决策](../testing/2026-09-08-ci-completion-observations.zh.md)保留其拥有的 fixture 完成与隔离决策;其已构建 Client 导入分类段落保留 sweep 摘要,并就被接受的证据链接到本文。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
-2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d
-2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f
+2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31
+2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0

+ 4 - 4
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md

@@ -12,9 +12,9 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe
 
 `command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit.
 
-`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback <text>` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it.
+`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback <text>` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor.
 
-The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code.
+The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure.
 
 ## Alternatives considered
 
@@ -24,9 +24,9 @@ The dialog is the shared Modal card at the design's width; the design's checkbox
 
 **Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs.
 
-**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike.
+**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves both message-rating paths and the Session dialog.
 
-**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition.
+**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; the File row uses the same `action` kind, so one definition serves both entries.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md

@@ -12,9 +12,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
 
 `command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。
 
-`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback <text>` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回
+`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback <text>` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器
 
-弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开
+弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误
 
 ## 考虑过的替代方案
 
@@ -24,9 +24,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
 
 **在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。
 
-**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径
+**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务两种消息评分路径与 Session 弹窗
 
-**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可
+**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;「文件」行使用同一个 `action` 种类,一份定义即可服务两个条目
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
-2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488
-2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef
+2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f
+2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2

+ 4 - 3
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md

@@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh
 
 `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
 
-Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
+Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
 
 `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
 
-Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
+Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
 
 ## Alternatives considered
 
@@ -32,12 +32,13 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac
 
 ## Testing
 
-The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
+The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
 
 ## Consequences
 
 - Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic.
 - A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes.
 - Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override.
+- The fixed 48-entry artwork table adds about 35 kB uncompressed and 17 kB gzip to the shared browser bundle; adding categories must justify that static baseline cost.
 - The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`.
 - The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust.

+ 4 - 3
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md

@@ -14,11 +14,11 @@ Status: implemented
 
 `FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
 
-已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
+已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
 
 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
 
-附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
+附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
 
 ## 备选方案
 
@@ -32,12 +32,13 @@ Status: implemented
 
 ## 测试
 
-`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
+`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
 
 ## 后果
 
 - 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。
 - 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。
 - 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。
+- 固定的 48 项图稿表为共享浏览器 bundle 增加约 35 kB 未压缩体积和 17 kB gzip 体积;新增类别必须证明这份静态基线成本是必要的。
 - primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。
 - 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.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-symmetric-message-feedback-submission.md
+2026-09-10-symmetric-message-feedback-submission.md: 51c754dbd3ecc566e15ec1b7a276fc2a3cf7c551
+2026-09-10-symmetric-message-feedback-submission.zh.md: 0ba5d1a1c53f08b76c5bbc32b84687c3dcd38ec2

+ 25 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md

@@ -0,0 +1,25 @@
+# Agent Note: Symmetric message feedback submission
+
+Status: implemented
+
+English | [中文](2026-09-10-symmetric-message-feedback-submission.zh.md)
+
+## Problem
+
+The assistant-message rating controls used different commit points. Like recorded a positive rating immediately, while Dislike opened the feedback dialog and recorded only after Submit. The asymmetry made an accidental Like durable before confirmation and prevented positive feedback from carrying the same optional category and description as negative feedback.
+
+## Decision
+
+The [feedback dialog and categories](2026-09-08-feedback-dialog-and-categories.md) decision owns the shared form and durable taxonomy. Both unrecorded ratings open the shared feedback dialog and record only after Submit. A message `FeedbackDialogTarget` carries the selected `positive` or `negative` rating, and `FeedbackSurface` passes that rating with the dialog entry to `MessageFeedbackController.rate`. The action row reads the committed item before either action: clicking its current rating calls the injected `retract` operation, while clicking an absent or opposite rating opens the dialog. `retract` rechecks the committed rating inside the controller's serialized mutation queue and becomes a no-op after a concurrent change, so it cannot turn stale UI intent into a bare rating put. The dialog remains optional-input: submitting without a category or description records the selected rating and raises the acknowledgement toast; dismissing it records nothing.
+
+## Alternatives considered
+
+**Keep Like as an immediate action.** This preserves one fewer click for positive feedback, but keeps two submission models beside each other and prevents positive reports from carrying context.
+
+**Require the dialog to retract a recorded rating.** Retraction has no category or description to collect, and an extra confirmation would make the existing undo action less direct.
+
+**Create separate positive and negative forms.** The fields, validation, failures, and acknowledgement are identical; carrying the rating in the existing target keeps one draft and submission lifecycle.
+
+## Consequences
+
+Neither rating creates a `feedback/message-put` event until the user submits the dialog. Positive and negative records can both include a category and note, while clicking the active rating continues to create `feedback/message-delete` without opening the dialog. Unit coverage pins the shared action path and target routing, and the keyless Web scenarios submit both ratings through the real dialog before checking durable events and telemetry release.

+ 25 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 对称消息反馈提交
+
+Status: implemented
+
+[English](2026-09-10-symmetric-message-feedback-submission.md) | 中文
+
+## 问题
+
+助手消息的两个评分控件使用不同的提交时点。点赞会立即记录正面评分,点踩则先打开反馈弹窗,仅在用户提交后记录。这种不对称会让误触点赞在确认前就持久化,也让正面反馈无法携带与负面反馈相同的可选分类和描述。
+
+## 决策
+
+[反馈弹窗与分类](2026-09-08-feedback-dialog-and-categories.zh.md)决策负责共用表单和持久化分类表。两种未记录的评分都打开共用反馈弹窗,并且只在提交后记录。消息 `FeedbackDialogTarget` 携带所选的 `positive` 或 `negative` 评分,`FeedbackSurface` 将该评分与弹窗条目一起传给 `MessageFeedbackController.rate`。动作行会在任一操作前读取已提交条目:点击当前评分会调用注入的 `retract` 操作,点击未记录或相反评分则打开弹窗。`retract` 会在控制器的串行变更队列内重新检查已提交评分,并在并发变更后变为无操作,因此不会把陈旧的 UI 意图转成裸评分 put。弹窗的输入仍可全部留空:不选分类也不填描述时提交会记录所选评分并弹出确认 toast;关闭弹窗不会记录任何内容。
+
+## 考虑过的替代方案
+
+**保留点赞立即提交。** 这能让正面反馈少一次点击,但会让并列的两个评分继续使用不同提交模式,也无法让正面反馈携带上下文。
+
+**撤回已记录评分也必须经过弹窗。** 撤回没有需要收集的分类或描述,多一次确认会让现有撤销操作变得不够直接。
+
+**分别创建正面和负面表单。** 两者的字段、校验、失败处理与确认完全相同;在现有目标中携带评分即可共用一套草稿与提交生命周期。
+
+## 后果
+
+用户提交弹窗前,两种评分都不会创建 `feedback/message-put` 事件。正面与负面记录都可以包含分类和备注,而点击当前评分仍会直接创建 `feedback/message-delete`,无需打开弹窗。单元测试固定共用动作路径与目标路由,无密钥 Web 场景则通过真实弹窗提交两种评分,再检查持久事件与遥测投递。

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.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-parallel-macos-notarization.md
+2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70
+2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75

+ 37 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md

@@ -0,0 +1,37 @@
+# Agent Note: Parallel macOS notarization from isolated App copies
+
+Status: implemented
+
+English | [中文](2026-09-09-parallel-macos-notarization.zh.md)
+
+## Problem
+
+The Desktop release distributes a DMG for installation and a ZIP for updates. Waiting for App notarization before creating the DMG serializes two Apple submissions. A proxy improves upload throughput but does not overlap the independent service waits. Stapling modifies the App, so concurrent notarization and packaging cannot safely share that writable directory.
+
+## Decision
+
+The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
+
+The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App.
+
+Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
+
+This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements.
+
+## Alternatives considered
+
+**Share one App between both lanes.** A DMG reader can overlap with `stapler` writes, producing a nondeterministic bundle. Independent copies keep the submitted and distributed bytes stable within each lane.
+
+**Only notarize the DMG.** ZIP updates are distributed independently and need a stapled App. Retaining both submissions keeps that independent qualification explicit.
+
+**Keep serial notarization and only use a proxy.** The same 214.84 MiB DMG uploads in 203.83 seconds directly and 38.59 seconds through the tested system proxy, but neither route removes the serial dependency between Apple submissions. Proxy configuration remains a build-host concern; the packaging script does not change host network settings.
+
+**Run both targets in one electron-builder call before App notarization finishes.** The ZIP must read the stapled copy. Separate prepackaged invocations preserve electron-builder's own archive, blockmap, and metadata implementation without changing its target scheduling or patching the dependency.
+
+## Consequences
+
+On 2026-09-09, full arm64 packaging on the same Mac through the same system proxy took 490.78 seconds with parallel notarization versus 751.33 seconds serially, a 34.7% reduction. The artifact lanes began 8 milliseconds apart and completed in 307.36 seconds for App/ZIP and 268.54 seconds for DMG. Apple accepted both submissions; their uploads completed about one second apart. Each configuration has one full-build sample, so cache and Apple queue variation prevent attributing the entire difference to concurrency.
+
+Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process.
+
+The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.

+ 37 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 基于隔离 App 副本的并行 macOS 公证
+
+Status: implemented
+
+[English](2026-09-09-parallel-macos-notarization.md) | 中文
+
+## 问题
+
+Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 App 公证完成后才创建 DMG,会让两次 Apple 提交串行执行。代理可以提高上传吞吐量,但不能让两个独立的服务等待过程重叠。钉票会修改 App,因此并发公证和打包不能安全地共享同一个可写目录。
+
+## 决策
+
+固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
+
+ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。
+
+错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
+
+本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。
+
+## 考虑过的替代方案
+
+**两路共享一个 App。** DMG 读取可能与 `stapler` 写入重叠,使 bundle 内容不确定。独立副本使每条路线中提交与分发的字节保持稳定。
+
+**只公证 DMG。** ZIP 更新独立分发,需要已钉票的 App。保留两次提交可以明确维持这项独立验收。
+
+**保留串行公证,仅使用代理。** 同一个 214.84 MiB DMG 直连上传耗时 203.83 秒,通过所测系统代理上传耗时 38.59 秒,但两种网络路径都不能消除 Apple 提交间的串行依赖。代理配置仍由构建主机负责;打包脚本不修改主机网络设置。
+
+**App 公证结束前,在同一次 electron-builder 调用中运行两个目标。** ZIP 必须读取已钉票副本。独立的 prepackaged 调用可以保留 electron-builder 自己的归档、blockmap 和元数据实现,无需修改目标调度或给依赖打补丁。
+
+## 影响
+
+2026-09-09,在同一台 Mac、同一系统代理下,arm64 完整打包在并行公证时耗时 490.78 秒,串行时耗时 751.33 秒,缩短 34.7%。两条产物路线相隔 8 毫秒启动,App/ZIP 耗时 307.36 秒,DMG 耗时 268.54 秒。Apple 接受了两次提交,两者上传完成时间仅相差约一秒。每种配置只有一次完整构建样本,缓存与 Apple 队列变化使我们不能将全部差值归因于并发。
+
+两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。
+
+[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.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-completion-observations.md
-2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c
-2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2
+2026-09-08-ci-completion-observations.md: e3de87a70419778407b5eb230cec64ce088dd0c0
+2026-09-08-ci-completion-observations.zh.md: 4e95d6fb9dc9b1ecac2e3d0dfa262b819de16261

+ 1 - 1
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md

@@ -30,7 +30,7 @@ The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.sp
 
 ### Built-client import classification
 
-The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts.
+The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for a `.css` file, and fails every other error and every unexpectedly successful exempt import; the [stylesheet exemption decision](../bug-fix/2026-09-10-built-bundle-css-exemption.md) owns which stylesheets that covers. Scoped resolve/load hooks exercise expected CSS failure, another stylesheet, another extension, arbitrary failure, another error code, and stale exemption without modifying shared build artifacts.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 ### 已构建 Client 的导入分类
 
-[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。
+[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对某个 `.css` 文件报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle,其他每个错误以及每个意外成功的豁免导入都会失败;该豁免覆盖哪些样式表由[样式表豁免决策](../bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)拥有。限定范围的 resolve/load hook 覆盖预期 CSS 失败、其他样式表、其他扩展名、任意失败、其他错误码和陈旧豁免,不修改共享构建产物。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml

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

+ 41 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -0,0 +1,41 @@
+# Agent Note: Hosted-image assumptions in the coverage suite
+
+Status: implemented
+
+English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md)
+
+## Problem
+
+The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
+
+## Decision
+
+Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image.
+
+Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path.
+
+`disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory.
+
+`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants.
+
+Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
+
+The stray-output sealing test in `packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies.
+
+The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
+
+The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption.
+
+## Alternatives considered
+
+**Excluding the coverage lanes from the hosted leg.** Rejected: the leg exists to run the same suite on another pool, and the failures named real host dependencies rather than a suite the pool cannot support.
+
+**Raising only the lane's per-test budget.** Rejected: a wider budget does not change the cases whose cost or behaviour is deterministic — a trapped ACP child still waits out both graces, and a coalesced reader still inflates the measured peak.
+
+**Keeping the `linux-scope.ts` probe mocks.** Rejected as inert: the platform pin selects the fallback path before either probe is called, so the mock changed no execution path.
+
+**Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded.
+
+## Consequences
+
+The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).

+ 41 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: coverage 套件里的托管镜像假设
+
+Status: implemented
+
+[English](2026-09-10-hosted-image-test-assumptions.md) | 中文
+
+## 问题
+
+[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
+
+## 决策
+
+每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。
+
+驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。
+
+`disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。
+
+`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。
+
+`packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
+
+`packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。
+
+Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
+
+Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。
+
+## 备选方案
+
+**把 coverage 通道排除出托管支路。** 否决:该支路的意义就是把同一套测试跑在另一个池上,而这些失败点出的是真实的宿主依赖,不是一个该池无法支撑的套件。
+
+**只抬高通道的每用例预算。** 否决:更宽的预算改变不了那些成本或行为确定的用例——被 trap 的 ACP 子进程仍然会等满两个宽限,被合并的读端仍然会抬高测得的峰值。
+
+**保留 `linux-scope.ts` 的探针 mock。** 因无效而否决:平台钉死会在任一探针被调用前就选中 fallback 路径,因此该 mock 没有改变任何执行路径。
+
+**削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。
+
+## 后果
+
+套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md
-2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448
-2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e
+2026-07-06-recallable-compaction.md: 07b89116b72a1c1aac2d79ee5aa41ff6e5d02a4c
+2026-07-06-recallable-compaction.zh.md: 7347cb08c94665a6e5bb88655ec08d04921d08eb

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md

@@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi
 - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor.
 - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint.
 
-Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
+Both use explicit asynchronous, paged history reads under the [synchronous event-read deprecation](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md) (non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
 
 ### Cache and cost
 
@@ -86,7 +86,7 @@ Deferred until observation calls for them:
 - **One summarize call emitting all outputs** — rejected: the summarize path has no structured-output enforcement; parsing one free-text response apart is the fragile boundary the fail-closed design avoids.
 - **Model-chosen chunk boundaries** — deferred: parse-and-validate cost against unproven value; chunk policy sits behind config.
 - **Model-authored pointers** — rejected: pointers must be exact; deterministic assembly is.
-- **FTS/vector index sidecar** — rejected in-session: the live log is in memory and bounded, a literal scan under budget suffices; an index earns its keep at cross-session scope.
+- **FTS/vector index sidecar** — the rejection based on a resident, bounded live log is superseded by the [event-read policy](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). Reassess the index choice against paged historical reads before implementing recall.
 - **Semantic search fallback / secondary-model extraction in the recall path** — rejected: an LLM or embedding call there breaks keyless replay determinism; recall stays a pure function of the log.
 - **Raw events instead of rendered transcript** — rejected: leaks log-only vocabulary and chunk noise; the model reads what a model once saw.
 - **Doing nothing (resume/fork as recovery)** — rejected: it makes recovery a human act.

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md

@@ -46,7 +46,7 @@ Status: proposed
 - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。
 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。
 
-两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
+两个工具都按[同步事件读取弃用规则](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)使用显式异步分页历史读取(拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
 
 ### 缓存与成本
 
@@ -86,7 +86,7 @@ Status: proposed
 - **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱边界。
 - **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略由配置控制。
 - **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。
-- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值
+- **FTS/向量索引伴随存储**:以实时日志常驻内存且大小有界为依据的否决,被[事件读取策略](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)取代。实现回溯前,需要针对分页历史读取重新评估索引选择
 - **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。
 - **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。
 - **什么都不做(用恢复/fork 补救)**:不予采纳,因为这会把恢复变成人工操作。

+ 8 - 0
.github/workflows/ci.yml

@@ -121,6 +121,14 @@ jobs:
       DSH_COVERAGE_MAX_WORKERS: '6'
       DSH_COVERAGE_PARTITIONS: '4'
       DSH_GATE_CONCURRENCY: '3'
+      # Managed-scope teardown cases run past the default per-test budget when
+      # this lane's partitions, workers, and sibling gates share one host: the
+      # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on
+      # the hosted image (run 34449848541) while the same commit stayed inside
+      # the budget on the in-house pool; the lane is green with this value
+      # (run 34457655892). The Windows coverage lane grants the same budget for
+      # the same reason.
+      DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
       # A gate failure aborts the sibling gate instead of waiting out its
       # multi-minute instrumented run.
       DSH_GATE_FAIL_FAST: '1'

+ 13 - 0
.oxlintrc.json

@@ -203,6 +203,19 @@
         "scripts/**/*.spec.{ts,tsx}"
       ],
       "rules": {
+        // Session assertions inspect the log; other deprecated APIs remain errors.
+        "typescript/no-deprecated": [
+          "error",
+          {
+            "allow": [
+              {
+                "from": "file",
+                "name": ["snapshotEvents", "eventAt", "ownEvents"],
+                "path": "packages/core/session/src/index.ts"
+              }
+            ]
+          }
+        ],
         "typescript/no-invalid-void-type": "error",
         "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence.
         "typescript/no-unnecessary-condition": "off",

+ 1 - 1
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
apps/desktop-host/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop-host",
   "description": "Private upstream-Node host process for the Electron desktop application",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "private": true,
   "license": "MIT",
   "type": "module",

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

+ 3 - 1
apps/desktop/README.md

@@ -129,10 +129,12 @@ pnpm run upload:mac:arm64
 
 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
 
-The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
+The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
 
 macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
 
+Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration.
+
 ### Unsigned Windows test installer
 
 On Windows x64, use the complete unsigned packaging command for local installation testing:

+ 3 - 1
apps/desktop/README.zh.md

@@ -129,10 +129,12 @@ pnpm run upload:mac:arm64
 
 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
 
-macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion 钩子 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有钩子成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
+macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
 
 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
 
+可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
+
 ### 未签名 Windows 测试安装包
 
 在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试:

+ 1 - 1
apps/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop",
   "description": "Electron desktop shell for a bundled dsh runtime and external plugins",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "private": true,
   "license": "MIT",
   "type": "module",

+ 122 - 0
apps/desktop/scripts/package-macos.ts

@@ -0,0 +1,122 @@
+/** Build the ZIP and DMG from separate signed application copies with overlapping notarization. */
+
+import { execFile } from 'node:child_process'
+import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
+import { basename, dirname, join } from 'node:path'
+import { promisify } from 'node:util'
+import { Arch, getArchSuffix } from 'electron-builder'
+import { notarize } from '@electron/notarize'
+import {
+  resolveMacOSNotarizationEnvironment,
+  resolveMacOSSigningEnvironment,
+} from './desktop-release-environment.mjs'
+import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs'
+import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs'
+
+const execute = promisify(execFile)
+
+/** One electron-builder artifact made from an already signed application. */
+export interface DesktopPrepackagedArtifact {
+  readonly format: 'dmg' | 'zip'
+  readonly appPath: string
+  readonly output: string
+}
+
+/** A signed macOS directory build and its final release destination. */
+export interface MacOSArtifactRequest {
+  readonly arch: 'arm64' | 'x64'
+  readonly version: string
+  readonly artifactsRoot: string
+  readonly environment: NodeJS.ProcessEnv
+}
+
+/** Apple-tool operations replaced by deterministic fixtures in orchestration tests. */
+export interface MacOSArtifactOperations {
+  readonly copyApp: (source: string, destination: string) => Promise<void>
+  readonly notarize: (options: ReturnType<typeof resolveMacOSNotarizationEnvironment> & { appPath: string }) => Promise<void>
+  readonly verifySignature: typeof verifyMacOSSignature
+  readonly verifyNotarization: typeof verifyMacOSNotarizedApplication
+}
+
+const operations: MacOSArtifactOperations = {
+  async copyApp(source, destination) {
+    await execute('/usr/bin/ditto', [source, destination])
+  },
+  notarize,
+  verifySignature: verifyMacOSSignature,
+  verifyNotarization: verifyMacOSNotarizedApplication,
+}
+
+async function timed(label: string, action: () => Promise<void>): Promise<void> {
+  const start = performance.now()
+  process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`)
+  await action()
+  process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`)
+}
+
+/**
+ * Notarize independent App/DMG copies concurrently, then promote their completed artifacts.
+ * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG
+ * carries its own ticket and encloses the signed App without an individually stapled ticket.
+ * @param request - Signed directory build, release version, architecture, and credentials.
+ * @param build - Runs electron-builder with publishing disabled; resolves only after its DMG
+ * notarization and verification hook succeeds, and rejects on build or hook failure.
+ * @param apple - Apple signing, copying, and notarization operations.
+ * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory.
+ */
+export async function packageMacOSArtifacts(
+  request: MacOSArtifactRequest,
+  build: (artifact: DesktopPrepackagedArtifact) => Promise<void>,
+  apple: MacOSArtifactOperations = operations,
+): Promise<void> {
+  const { arch, version, artifactsRoot, environment } = request
+  const expected = resolveMacOSSigningEnvironment(environment)
+  const credentials = resolveMacOSNotarizationEnvironment(environment)
+  const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app')
+  const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-'))
+  const zipApp = join(root, 'zip', basename(appPath))
+  const dmgApp = join(root, 'dmg', basename(appPath))
+  const zipOutput = join(root, 'zip-artifacts')
+  const dmgOutput = join(root, 'dmg-artifacts')
+  try {
+    await apple.copyApp(appPath, zipApp)
+    await apple.copyApp(appPath, dmgApp)
+    apple.verifySignature(zipApp, expected)
+    apple.verifySignature(dmgApp, expected)
+    const results = await Promise.allSettled([
+      timed('App notarization and ZIP', async () => {
+        await apple.notarize({ appPath: zipApp, ...credentials })
+        apple.verifyNotarization(zipApp, expected)
+        await build({ format: 'zip', appPath: zipApp, output: zipOutput })
+      }),
+      timed('DMG creation and notarization', async () => {
+        await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
+      }),
+    ])
+    const failures = results.filter(result => result.status === 'rejected')
+    if (failures.length > 0) {
+      throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed')
+    }
+    const base = `deepseek-harness-${version}-mac-${arch}`
+    const artifacts = [
+      [dmgOutput, `${base}.dmg`],
+      [zipOutput, `${base}.zip`],
+      [zipOutput, `${base}.zip.blockmap`],
+      [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')],
+    ] as const
+    for (const [output, filename] of artifacts) {
+      const file = join(output, filename)
+      const details = await stat(file)
+      if (!details.isFile() || details.size === 0) {
+        throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`)
+      }
+    }
+    for (const [output, filename] of artifacts) {
+      await rename(join(output, filename), join(artifactsRoot, filename))
+    }
+    await rm(appPath, { recursive: true })
+    await rename(zipApp, appPath)
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+}

+ 23 - 1
apps/desktop/scripts/package-target.ts

@@ -9,6 +9,7 @@ import {
   resolveDesktopAutoUpdateConfig,
 } from './desktop-auto-update-environment.mjs'
 import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
+import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -217,11 +218,13 @@ export function parseDesktopPackageInvocation(
  * Build the electron-builder command arguments for one validated target.
  * @param target - Supported release target.
  * @param directory - Whether to stop at an unpacked application directory.
+ * @param artifact - Optional single artifact built from an existing signed application.
  * @returns Arguments that keep publishing under the separate validated upload command.
  */
 export function desktopElectronBuilderArguments(
   target: DesktopPackageTarget,
   directory: boolean,
+  artifact?: DesktopPrepackagedArtifact,
 ): readonly string[] {
   return [
     'exec',
@@ -229,10 +232,16 @@ export function desktopElectronBuilderArguments(
     '--config',
     'electron-builder.config.mjs',
     target.builderPlatform,
+    ...(artifact === undefined ? [] : [artifact.format]),
     target.builderArch,
     '--publish',
     'never',
     ...(directory ? ['--dir'] : []),
+    ...(artifact === undefined ? [] : [
+      ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []),
+      '--prepackaged', artifact.appPath,
+      '--config.directories.output', artifact.output,
+    ]),
   ]
 }
 
@@ -302,7 +311,20 @@ async function main(): Promise<void> {
   await runPnpm(['run', 'prepare:packages'], targetEnv)
   await runPnpm(['run', 'prepare:dsh'], targetEnv)
   if (invocation.prepareOnly) return
-  await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
+  if (target.platform === 'darwin' && !invocation.directory) {
+    await runPnpm([
+      ...desktopElectronBuilderArguments(target, true),
+      '--config.mac.notarize=false',
+    ], electronBuilderEnv)
+    await packageMacOSArtifacts({
+      arch: target.arch,
+      version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
+      artifactsRoot: buildPaths.artifacts,
+      environment: electronBuilderEnv,
+    }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
+  } else {
+    await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
+  }
   if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }
 

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

@@ -41,6 +41,13 @@ export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvir
  */
 export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void
 
+/**
+ * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance.
+ * @param appPath - Path to the stapled `.app` directory.
+ * @param expected - Public release identity.
+ */
+export function verifyMacOSNotarizedApplication(appPath: string, expected: MacOSSigningEnvironment): void
+
 /**
  * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
  * @param diskImagePath - Path to the packaged `.dmg` file.

+ 12 - 0
apps/desktop/scripts/verify-macos-signature.mjs

@@ -147,6 +147,18 @@ export function verifyMacOSSignature(appPath, expected) {
   assertMacOSSignatureDetails(details, expected)
 }
 
+/**
+ * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance.
+ * @param {string} appPath - Path to the stapled `.app` directory.
+ * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
+ * @returns {void}
+ */
+export function verifyMacOSNotarizedApplication(appPath, expected) {
+  verifyMacOSSignature(appPath, expected)
+  runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', appPath], 'stapler validate')
+  runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'spctl')
+}
+
 /**
  * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
  * @param {string} diskImagePath - Path to the packaged `.dmg` file.

+ 45 - 0
apps/desktop/tests/macos-notarized-application.spec.ts

@@ -0,0 +1,45 @@
+/** Verify application qualification commands without invoking Apple tools. */
+
+import { spawnSync } from 'node:child_process'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { verifyMacOSNotarizedApplication } from '../scripts/verify-macos-signature.mjs'
+
+vi.mock('node:child_process', async importOriginal => ({
+  ...await importOriginal<typeof import('node:child_process')>(),
+  spawnSync: vi.fn(),
+}))
+
+const expected = { signingIdentity: 'Example Company (TEAMID1234)', teamId: 'TEAMID1234' }
+const appPath = '/private build/DeepSeek Harness.app'
+const commands = [
+  ['/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]],
+  ['/usr/bin/codesign', ['--display', '--verbose=4', appPath]],
+  ['/usr/bin/xcrun', ['stapler', 'validate', appPath]],
+  ['/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]],
+] as const
+
+afterEach(() => { vi.resetAllMocks() })
+
+describe('notarized application qualification', () => {
+  it.each([undefined, 0, 1, 2, 3])('stops at failed command %s or verifies every qualification', (failedCommand) => {
+    let index = 0
+    vi.mocked(spawnSync).mockImplementation(() => ({
+      pid: 1,
+      output: [],
+      stdout: '',
+      stderr: `Authority=Developer ID Application: ${expected.signingIdentity}\nTeamIdentifier=${expected.teamId}\n`,
+      status: index++ === failedCommand ? 1 : 0,
+      signal: null,
+    }))
+    if (failedCommand === undefined) {
+      expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).not.toThrow()
+    } else {
+      expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).toThrow('exited with 1')
+    }
+    const calledCommands = commands.slice(0, failedCommand === undefined ? commands.length : failedCommand + 1)
+    expect(spawnSync).toHaveBeenCalledTimes(calledCommands.length)
+    for (const [index, [command, args]] of calledCommands.entries()) {
+      expect(spawnSync).toHaveBeenNthCalledWith(index + 1, command, args, { encoding: 'utf8' })
+    }
+  })
+})

+ 190 - 0
apps/desktop/tests/package-macos.spec.ts

@@ -0,0 +1,190 @@
+/** Exercise notarization overlap and artifact isolation without Apple credentials or network. */
+
+import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import {
+  packageMacOSArtifacts,
+  type DesktopPrepackagedArtifact,
+  type MacOSArtifactOperations,
+} from '../scripts/package-macos.ts'
+import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts'
+
+const environment = {
+  DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
+  DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
+  APPLE_KEYCHAIN_PROFILE: 'fixture-profile',
+}
+
+function barrier() {
+  let release!: () => void
+  const promise = new Promise<void>((resolve) => { release = resolve })
+  return { promise, release }
+}
+
+async function fixture(arch: 'arm64' | 'x64' = 'arm64') {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-'))
+  const artifactsRoot = join(root, 'artifacts')
+  const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
+  await mkdir(appPath, { recursive: true })
+  await writeFile(join(appPath, 'payload'), 'signed content')
+  const version = '1.2.3-alpha.1'
+  const base = `deepseek-harness-${version}-mac-${arch}`
+  const request = { arch, artifactsRoot, version, environment }
+  const apple: MacOSArtifactOperations = {
+    copyApp: async (source, destination) => {
+      await cp(source, destination, { recursive: true, verbatimSymlinks: true })
+    },
+    notarize: async ({ appPath: path }) => { await writeFile(join(path, 'ticket'), 'accepted') },
+    verifySignature: vi.fn(),
+    verifyNotarization: vi.fn((path: string) => {
+      if (!existsSync(join(path, 'ticket'))) throw new Error('missing App ticket')
+    }),
+  }
+  const build = async (artifact: DesktopPrepackagedArtifact) => {
+    await mkdir(artifact.output, { recursive: true })
+    const contents = JSON.stringify({
+      payload: await readFile(join(artifact.appPath, 'payload'), 'utf8'),
+      appTicket: existsSync(join(artifact.appPath, 'ticket')),
+    })
+    await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents)
+    if (artifact.format === 'zip') {
+      await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap')
+      await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata')
+    }
+  }
+  return { root, appPath, request, apple, build, base }
+}
+
+describe('parallel macOS artifacts', () => {
+  it.each(['arm64', 'x64'] as const)('overlaps notarization on isolated %s copies and promotes only completed payloads', async (arch) => {
+    const f = await fixture(arch)
+    const appStarted = barrier()
+    const appAccepted = barrier()
+    const dmgCompleted = barrier()
+    const zipCompleted = barrier()
+    const starts: string[] = []
+    const copies: string[] = []
+    const operation = packageMacOSArtifacts(f.request, async (artifact) => {
+      starts.push(artifact.format)
+      if (artifact.format === 'dmg') await dmgCompleted.promise
+      await f.build(artifact)
+      if (artifact.format === 'zip') zipCompleted.release()
+    }, {
+      ...f.apple,
+      copyApp: async (source, destination) => {
+        copies.push(destination)
+        await f.apple.copyApp(source, destination)
+      },
+      notarize: async (options) => {
+        starts.push('app')
+        appStarted.release()
+        await appAccepted.promise
+        await f.apple.notarize(options)
+      },
+    })
+    try {
+      await appStarted.promise
+      await vi.waitFor(() => { expect([...starts]).toEqual(expect.arrayContaining(['app', 'dmg'])) })
+      expect(new Set(copies).size).toBe(2)
+      expect(copies.every(path => path !== f.appPath)).toBe(true)
+      appAccepted.release()
+      await zipCompleted.promise
+      expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
+      expect(existsSync(join(f.request.artifactsRoot, `${f.base}.zip`))).toBe(false)
+      dmgCompleted.release()
+      await operation
+      expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.zip`), 'utf8')))
+        .toEqual({ payload: 'signed content', appTicket: true })
+      expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8')))
+        .toEqual({ payload: 'signed content', appTicket: false })
+      expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted')
+      expect((await readdir(f.root)).sort()).toEqual(['artifacts'])
+      expect(f.apple.verifySignature).toHaveBeenCalledTimes(2)
+      expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1)
+    } finally {
+      appAccepted.release()
+      dmgCompleted.release()
+      await Promise.allSettled([operation])
+      await rm(f.root, { recursive: true, force: true })
+    }
+  })
+
+  it('collects both failures after both lanes release their copies and publishes neither payload', async () => {
+    const f = await fixture()
+    const appStarted = barrier()
+    const failApp = barrier()
+    const failDmg = barrier()
+    const appError = new Error('App rejected')
+    const dmgError = new Error('DMG rejected')
+    const released: string[] = []
+    const outcome = packageMacOSArtifacts(f.request, async (artifact) => {
+      expect(artifact.format).toBe('dmg')
+      await failDmg.promise
+      expect(await readFile(join(artifact.appPath, 'payload'), 'utf8')).toBe('signed content')
+      released.push('dmg')
+      throw dmgError
+    }, {
+      ...f.apple,
+      notarize: async () => {
+        appStarted.release()
+        await failApp.promise
+        released.push('app')
+        throw appError
+      },
+    }).catch((error: unknown) => error)
+    try {
+      await appStarted.promise
+      failApp.release()
+      failDmg.release()
+      const error = await outcome
+      expect(error).toBeInstanceOf(AggregateError)
+      expect((error as AggregateError).errors).toEqual([appError, dmgError])
+      expect(released.sort()).toEqual(['app', 'dmg'])
+      expect(await readdir(f.root)).toEqual(['artifacts'])
+      expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
+      expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
+    } finally {
+      failApp.release()
+      failDmg.release()
+      await outcome
+      await rm(f.root, { recursive: true, force: true })
+    }
+  })
+
+  it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => {
+    const f = await fixture()
+    try {
+      const apple: MacOSArtifactOperations = {
+        ...f.apple,
+        ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}),
+        ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}),
+        ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}),
+      }
+      await expect(packageMacOSArtifacts(f.request, async (artifact) => {
+        await f.build(artifact)
+        if (failure === 'metadata' && artifact.format === 'zip') {
+          await writeFile(join(artifact.output, 'alpha-mac.yml'), '')
+        }
+      }, apple)).rejects.toThrow()
+      expect(await readdir(f.root)).toEqual(['artifacts'])
+      expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
+    } finally { await rm(f.root, { recursive: true, force: true }) }
+  })
+
+  it('passes the actual App and isolated output directory to each single-target builder', () => {
+    const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')
+    for (const format of ['zip', 'dmg'] as const) {
+      const appPath = join('private build', format, 'DeepSeek Harness.app')
+      const output = join(dirname(appPath), 'artifacts')
+      expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([
+        'exec', 'electron-builder', '--config', 'electron-builder.config.mjs',
+        '--mac', format, '--arm64', '--publish', 'never',
+        '--config.mac.notarize=false',
+        '--prepackaged', appPath, '--config.directories.output', output,
+      ])
+    }
+  })
+})

+ 1 - 1
apps/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 9 - 4
apps/web/tests/feedback-release.e2e.ts

@@ -218,12 +218,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     const like = page.getByRole('button', { name: 'Good response' })
     await like.hover()
     await like.click()
+    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
+    await dialog.getByRole('button', { name: 'Instruction understanding and following', exact: true }).click()
+    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Clear and complete.')
+    expect(captured()).toHaveLength(releasedCount)
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await expect.poll(() => dialog.count()).toBe(0)
     const rated = page.getByRole('button', { name: 'Remove rating' })
     await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true')
     await expectFeedbackRelease('feedback/message-put', 1)
-    // Dislike collects the category and note in the dialog; typing releases nothing.
+    // The second rating uses the same dialog; typing releases nothing.
     await page.getByRole('button', { name: 'Bad response' }).click()
-    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
     await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
     await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.')
     expect(captured()).toHaveLength(releasedCount)
@@ -242,7 +247,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
       { data: { text: 'the second remark' } },
     ])
     expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([
-      { data: { sessionId, item: { rating: 'positive' } } },
+      { data: { sessionId, item: { rating: 'positive', note: 'Clear and complete.', category: 'instruction-following' } } },
       { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } },
     ])
     expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }])
@@ -251,7 +256,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     expect(captured()).toHaveLength(releasedCount)
     const wire = uploads.join('\n')
     for (const text of ['the diff view is unreadable', 'the second remark',
-      'Read both files before answering.']) expect(wire).toContain(text)
+      'Clear and complete.', 'Read both files before answering.']) expect(wire).toContain(text)
     const feedback = events.flatMap<Record<string, string | undefined>>((event) => {
       switch (event.type) {
         case 'feedback/record': return [{ type: event.type, text: event.data.text }]

+ 25 - 14
apps/web/tests/message-feedback.e2e.ts

@@ -1,8 +1,8 @@
 // Keyless browser regression for durable per-message feedback. Cold-seeds a
-// settled two-turn transcript (zero model calls), likes one assistant message
-// and sees the acknowledgement, replaces the Like through the Dislike dialog
-// with a category and a note, proves the judgment survives a full page reload
-// from the Host's canonical log, then retracts it.
+// settled two-turn transcript (zero model calls), records a Like through the
+// feedback dialog, replaces it through the same dialog with a Dislike, proves
+// the judgment survives a full page reload from the Host's canonical log, then
+// retracts it.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -20,6 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'message-feedback-web-e2e'
+const POSITIVE_NOTE = 'Clear and complete.'
 const NOTE = 'Read both files before answering.'
 
 describe('web e2e: durable per-message feedback', () => {
@@ -58,7 +59,7 @@ describe('web e2e: durable per-message feedback', () => {
     await sessionRow.click()
   }
 
-  it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => {
+  it.skipIf(MODE === 'record')('submits both ratings through the dialog, persists the Dislike, then retracts it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
     await openSeededSession()
 
@@ -71,21 +72,29 @@ describe('web e2e: durable per-message feedback', () => {
     await like.scrollIntoViewIfNeeded()
     await like.hover()
     await like.click()
-    // A Like records at once and is acknowledged; a recorded rating relabels
-    // the button to what the next click would do.
+    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Stability and speed', exact: true }).click()
+    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(POSITIVE_NOTE)
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
     await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 })
     const rated = page.getByRole('button', { name: 'Remove rating' }).first()
     await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true')
 
-    // Dislike opens the Session's feedback dialog; its submission replaces
-    // the Like with a negative judgment carrying the category and note.
+    // The same dialog records a negative judgment with its own category and
+    // note, replacing the positive judgment only after submission.
     await page.getByRole('button', { name: 'Bad response' }).first().click()
-    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
     await dialog.waitFor({ timeout: 10_000 })
     await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder'))
       .toBe('Add details to help us improve. Your submission will include the current conversation log.')
     await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
-    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE)
+    const details = dialog.getByRole('textbox', { name: 'Feedback details' })
+    await details.fill('x'.repeat(8193))
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await page.getByRole('alert').filter({ hasText: 'The description is too long' }).waitFor({ timeout: 10_000 })
+    expect(await dialog.count()).toBe(1)
+    await details.fill(NOTE)
     await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
     await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating')
@@ -116,9 +125,11 @@ describe('web e2e: durable per-message feedback', () => {
     await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false')
     const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
     if (agent === undefined) throw new Error('seeded session did not attach an agent')
-    const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1)
-    expect(put?.type === 'feedback/message-put' ? put.data.item : undefined)
-      .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' })
+    const puts = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put')
+    expect(puts.map(event => event.type === 'feedback/message-put' ? event.data.item : undefined)).toMatchObject([
+      { rating: 'positive', note: POSITIVE_NOTE, category: 'service-stability' },
+      { rating: 'negative', note: NOTE, category: 'task-result' },
+    ])
 
     // Re-clicking the active rating retracts it, and the note goes with it.
     await restored.click()

+ 67 - 0
apps/web/tests/present.e2e.ts

@@ -184,6 +184,73 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       expect(await failed.innerText()).toContain('Delivery failed')
       expect(await delivered.innerText()).toContain('Delivered')
       await page.locator('[data-turn-process]').click()
+      const geometry = await page.evaluate(() => {
+        const requiredElement = <T extends Element>(value: T | null | undefined, name: string): T => {
+          if (value === null || value === undefined) throw new Error(`present layout is missing ${name}`)
+          return value
+        }
+        const answer = requiredElement(
+          [...document.querySelectorAll<HTMLElement>('[data-chat-flow-kind="assistant-step"]')]
+            .find(element => element.textContent?.includes('PRESENT_DONE')),
+          'final answer',
+        )
+        const presentedGrid = requiredElement(
+          document.querySelector<HTMLElement>('[data-presented-files-row]'),
+          'presented grid',
+        )
+        const presentedRoot = requiredElement(presentedGrid.parentElement, 'presented root')
+        const turnTail = requiredElement(presentedRoot.closest<HTMLElement>('[data-turn-tail]'), 'turn tail')
+        const actions = requiredElement(
+          turnTail.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.parentElement,
+          'action row',
+        )
+        const cards = [...presentedGrid.querySelectorAll<HTMLElement>('[data-presented-file]')]
+        const report = requiredElement(
+          cards.find(card => card.textContent?.includes('report.txt')),
+          'report card',
+        )
+        const title = requiredElement(
+          report.querySelector<HTMLElement>('span[title="report.txt"]')
+            ?? [...report.querySelectorAll<HTMLElement>('span')]
+              .find(element => element.textContent === 'report.txt'),
+          'report title',
+        )
+        const description = requiredElement(report.querySelector<HTMLElement>('span[role="status"]'), 'report status')
+        const open = requiredElement(
+          report.querySelector<HTMLButtonElement>('button[aria-label="Open report.txt in sidebar"]'),
+          'report open action',
+        )
+        const icon = requiredElement(report.querySelector<SVGElement>('svg'), 'report icon')
+        const secondCard = requiredElement(cards[1], 'second card')
+        const answerRect = answer.getBoundingClientRect()
+        const presentedRect = presentedRoot.getBoundingClientRect()
+        const actionsRect = actions.getBoundingClientRect()
+        const firstCard = report.getBoundingClientRect()
+        const secondCardRect = secondCard.getBoundingClientRect()
+        const gridStyle = getComputedStyle(presentedGrid)
+        return {
+          answerToPresented: presentedRect.top - answerRect.bottom,
+          presentedToActions: actionsRect.top - presentedRect.bottom,
+          cardHeight: firstCard.height,
+          cardColumnGap: secondCardRect.left - firstCard.right,
+          gridColumnGap: gridStyle.columnGap,
+          gridRowGap: gridStyle.rowGap,
+          iconWidth: icon.getAttribute('width'),
+          titleFontSize: getComputedStyle(title).fontSize,
+          descriptionFontSize: getComputedStyle(description).fontSize,
+          openFontSize: getComputedStyle(open).fontSize,
+        }
+      })
+      expect(geometry.answerToPresented).toBeCloseTo(20, 1)
+      expect(geometry.presentedToActions).toBeCloseTo(20, 1)
+      expect(geometry.cardHeight).toBeCloseTo(60, 1)
+      expect(geometry.cardColumnGap).toBeCloseTo(10, 1)
+      expect(geometry.gridColumnGap).toBe('10px')
+      expect(geometry.gridRowGap).toBe('10px')
+      expect(geometry.iconWidth).toBe('20')
+      expect(geometry.titleFontSize).toBe('13px')
+      expect(geometry.descriptionFontSize).toBe('10px')
+      expect(geometry.openFontSize).toBe('12px')
       await page.setViewportSize({ width: 480, height: 900 })
       const row = page.locator('[data-presented-files-row]')
       await row.scrollIntoViewIfNeeded()

+ 31 - 0
apps/web/tests/produced-files.e2e.ts

@@ -171,6 +171,37 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
     expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
+    const turnSpacing = await page.evaluate((done) => {
+      const requiredElement = <T extends Element>(value: T | null | undefined, name: string): T => {
+        if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`)
+        return value
+      }
+      const answer = requiredElement(
+        [...document.querySelectorAll<HTMLElement>('[data-chat-flow-kind="assistant-step"]')]
+          .find(element => element.textContent?.includes(done)),
+        'final answer',
+      )
+      const producedRow = requiredElement(
+        document.querySelector<HTMLElement>('[data-produced-files-row]'),
+        'produced row',
+      )
+      const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root')
+      const turnTail = requiredElement(producedRoot.closest<HTMLElement>('[data-turn-tail]'), 'turn tail')
+      const actions = requiredElement(
+        turnTail.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.parentElement,
+        'action row',
+      )
+      const answerRect = answer.getBoundingClientRect()
+      const producedRect = producedRoot.getBoundingClientRect()
+      const actionsRect = actions.getBoundingClientRect()
+      return {
+        answerToProduced: producedRect.top - answerRect.bottom,
+        producedToActions: actionsRect.top - producedRect.bottom,
+      }
+    }, DONE)
+    expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1)
+    expect(turnSpacing.producedToActions).toBeCloseTo(20, 1)
+
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))
     expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 7581e5ee55fefc6b8ff85d6215b3645f183ecb73
-config-catalog.zh.md: 45d457573807cf63940b8eef4609c02c2dee65c2
+config-catalog.md: 3e1e0f4cd22c9a9775b0c12e162e1477eb277f9d
+config-catalog.zh.md: 0b6a2b7a91f3030e5dce7d00a9d5178e096932bb

+ 1 - 1
docs/config-catalog.md

@@ -3240,7 +3240,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-Source: [`packages/interaction/user-approval/src/index.ts:127`](../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts)
 
 <a id="deepseek-aidsh-web"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -3242,7 +3242,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts)
+来源:[`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts)
 
 <a id="deepseek-aidsh-web"></a>
 

+ 2 - 2
docs/subsystems/feedback.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 docs/subsystems/feedback.md
-feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5
-feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99
+feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03
+feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7

+ 3 - 3
docs/subsystems/feedback.md

@@ -27,7 +27,7 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
-  /** Category the human filed a negative judgment under. */
+  /** Category the human filed the judgment under. */
   readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
@@ -298,9 +298,9 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session
 
 The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows.
 
-One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read.
+One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. The injected `retract` operation rechecks the committed rating inside that queue and becomes a no-op after a concurrent change, so stale UI cannot bypass the dialog by recording a bare rating. A `connection/reset` refreshes only Sessions already read.
 
-Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback <text>` keeps the Host command path. Clicking a recorded rating retracts it.
+Either unrecorded rating opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts the selected judgment carrying the chosen category and the trimmed description, or neither; success closes the dialog and shows the acknowledgement toast, while failure keeps the dialog and draft open and shows a warning toast. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback <text>` keeps the Host command path. Clicking a recorded rating retracts it without opening the dialog.
 
 ## Boundaries and limitations
 

+ 3 - 3
docs/subsystems/feedback.zh.md

@@ -27,7 +27,7 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
-  /** Category the human filed a negative judgment under. */
+  /** Category the human filed the judgment under. */
   readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
@@ -298,9 +298,9 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 
 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。
 
-每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。
+每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。注入的 `retract` 操作会在该队列内重新检查已提交评分,并在并发变更后变为无操作,因此陈旧 UI 无法绕过弹窗记录裸评分。`connection/reset` 只刷新已读取过的 Session。
 
-点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback <text>` 仍走宿主命令路径。再次点击已记录的评分会撤回它
+任一未记录的评分都会打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 所选评分,带上所选分类与去除首尾空白的描述,两者也可都不带;成功会关闭弹窗并显示确认 toast,失败则保留弹窗与草稿并显示警告 toast。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback <text>` 仍走宿主命令路径。再次点击已记录的评分会直接撤回,不打开弹窗
 
 ## 边界与限制
 

+ 2 - 2
docs/subsystems/session.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 docs/subsystems/session.md
-session.md: 2b99ca8267d35978f245e6bdbf020226c976e363
-session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b
+session.md: 7157dfd403d005112a75d0bbb46f99ad385b68df
+session.zh.md: 6cb157a8016f236f52d4f8963e6efe070f0b89e3

+ 6 - 0
docs/subsystems/session.md

@@ -473,6 +473,8 @@ declare class Session {
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param seq - event sequence number.
    * @returns the accepted event, or undefined when the log does not contain it.
    */
@@ -481,6 +483,8 @@ declare class Session {
    * Materialize an immutable snapshot of a half-open event sequence range.
    * A full current snapshot is reused until the next append; every previously
    * returned snapshot remains stable after later appends.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
    * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
    * @returns a frozen array of the selected deeply frozen events.
@@ -491,6 +495,8 @@ declare class Session {
   ): readonly SessionEvent[];
   /**
    * Return this Session's events after its fork-inherited prefix.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @returns a fresh array containing child-owned events in log order.
    */
   ownEvents(): readonly SessionEvent[];

+ 6 - 0
docs/subsystems/session.zh.md

@@ -475,6 +475,8 @@ declare class Session {
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param seq - event sequence number.
    * @returns the accepted event, or undefined when the log does not contain it.
    */
@@ -483,6 +485,8 @@ declare class Session {
    * Materialize an immutable snapshot of a half-open event sequence range.
    * A full current snapshot is reused until the next append; every previously
    * returned snapshot remains stable after later appends.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
    * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
    * @returns a frozen array of the selected deeply frozen events.
@@ -493,6 +497,8 @@ declare class Session {
   ): readonly SessionEvent[];
   /**
    * Return this Session's events after its fork-inherited prefix.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @returns a fresh array containing child-owned events in log order.
    */
   ownEvents(): readonly SessionEvent[];

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "license": "MIT",
   "private": true,
   "type": "module",

+ 1 - 1
packages/acp/acp/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp",
   "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/gateway/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-gateway",
   "description": "Typert Remote Host dispatcher and Client API endpoint",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/remotes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-remotes",
   "description": "Remote BFF assembly for application-selected Host capabilities",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/session-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-session-controller",
   "description": "Session Remote commands, cold reads, and live control transport",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 0
packages/api/session-controller/src/commands.ts

@@ -541,6 +541,7 @@ export class SessionCommandController {
   private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
     const attached = this.ctx.sessions.get(sessionId)
     if (attached !== undefined) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }
     }
     const inspected = await inspectApiSession(this.ctx, sessionId)
@@ -587,6 +588,7 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
     return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
   }
   if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   return agent.session.snapshotEvents().some((event) => {
     if (event.type !== 'user/message') return false
     const source = event.data.source

+ 1 - 0
packages/api/session-controller/src/history.ts

@@ -152,6 +152,7 @@ export class SessionHistoryController {
       // Constructor seed events have no session/event notification. Normally
       // only the end-seed suffix is new; if persistence advanced after the
       // opening observation, replay everything beyond that snapshot cursor.
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const suffix = session.snapshotEvents(snapshotCursor === undefined
         ? session.firstLiveSeq
         : SessionLogOffset(snapshotCursor + 1))

+ 1 - 0
packages/api/session-controller/src/index.ts

@@ -207,6 +207,7 @@ export class SessionController extends TypertRemoteService {
       return Promise.resolve({
         meta: attached.header,
         inheritedEventCount: attached.inheritedEventCount,
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         events: attached.snapshotEvents(),
       })
     }

+ 1 - 1
packages/api/settings-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-settings-controller",
   "description": "Remote owner for the configuration surfaces over the settings-domain seams",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-controller",
   "description": "Workspace Remote commands and reconnect-safe state transport",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-files/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-files",
   "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment-local/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment-local",
   "description": "Private content-addressed DSH_HOME attachment storage",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment",
   "description": "Durable immutable attachment storage seam for the DeepSeek Harness",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/app-boot/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-app-boot",
   "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/cmdline/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-cmdline",
   "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/acp-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp-app",
   "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/base/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-base",
   "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/headless/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-headless",
   "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 0
packages/bundle/headless/src/index.ts

@@ -67,6 +67,7 @@ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
   let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
   const length = session.seq
   for (let seq = firstSeq; seq < length; seq++) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = session.eventAt(SessionSeq(seq))
     if (event === undefined) {
       throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`)

+ 1 - 1
packages/bundle/sdk-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-app",
   "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-minimal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-minimal",
   "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/web-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-app",
   "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969
-README.zh.md: 5805ad129fc5d1f241fb6594adc7a5f00ca3d0e8
+README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd
+README.zh.md: 8183ef33ca7e08bec2533eb2e21486809c94cedd

+ 1 - 1
packages/client/README.md

@@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
-| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — |
+| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |

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

@@ -71,7 +71,7 @@ kind: "package-group"
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
-| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — |
+| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |

+ 1 - 1
packages/client/connection/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-connection",
   "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/file-upload/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-file-upload",
   "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/hmr/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
   "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/locale/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-locale",
   "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/modules/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
   "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-resources",
   "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/store/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-store",
   "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-agent-preset/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-agent-preset",
   "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-approval/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-approval",
   "description": "Approval composer takeover over the scoped Remote Event waterfall",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
   "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-brand-official/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
   "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

Некоторые файлы не были показаны из-за большого количества измененных файлов