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

Merge master into thin Electron Web UI and reconcile runtime resolution

07akioni 3 дней назад
Родитель
Сommit
bafe2b7d55
100 измененных файлов с 1898 добавлено и 273 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
  8. 4 4
      .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
  9. 4 4
      .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
  11. 1 1
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
  12. 1 1
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.i18n.yaml
  14. 2 2
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md
  15. 2 2
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.i18n.yaml
  17. 2 2
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.md
  18. 2 2
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml
  20. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
  21. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  23. 3 3
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  24. 3 3
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  25. 6 0
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml
  26. 118 0
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
  27. 118 0
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
  28. 6 0
      .agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.i18n.yaml
  29. 28 0
      .agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md
  30. 28 0
      .agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md
  31. 6 0
      .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml
  32. 64 0
      .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md
  33. 64 0
      .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md
  34. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.i18n.yaml
  35. 25 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.md
  36. 25 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.zh.md
  37. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.i18n.yaml
  38. 33 0
      .agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.md
  39. 33 0
      .agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.zh.md
  40. 6 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml
  41. 41 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
  42. 41 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md
  43. 2 2
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml
  44. 3 1
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
  45. 3 1
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md
  46. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  47. 5 1
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  48. 5 1
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  49. 6 0
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.i18n.yaml
  50. 35 0
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md
  51. 35 0
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md
  52. 6 0
      .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml
  53. 33 0
      .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md
  54. 33 0
      .agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md
  55. 6 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.i18n.yaml
  56. 116 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md
  57. 116 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md
  58. 2 0
      THIRD_PARTY_NOTICES.md
  59. 1 1
      apps/cli/package.json
  60. 2 2
      apps/cli/reference/README.i18n.yaml
  61. 3 3
      apps/cli/reference/README.md
  62. 3 3
      apps/cli/reference/README.zh.md
  63. 22 5
      apps/cli/src/profile-boot.ts
  64. 1 0
      apps/cli/tests/built-bin.e2e.ts
  65. 4 0
      apps/cli/tests/profiles/headless/goal-snapshot.patch.yml
  66. 4 0
      apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml
  67. 4 0
      apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml
  68. 4 0
      apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml
  69. 4 0
      apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml
  70. 4 0
      apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml
  71. 14 6
      apps/cli/tests/resolved-profile-boot.spec.ts
  72. 20 8
      apps/cli/tests/web-agent-presets.e2e.ts
  73. 1 1
      apps/desktop-host/package.json
  74. 1 0
      apps/desktop-host/src/index.ts
  75. 2 2
      apps/desktop/README.i18n.yaml
  76. 5 5
      apps/desktop/README.md
  77. 5 5
      apps/desktop/README.zh.md
  78. 9 4
      apps/desktop/electron-builder.config.d.mts
  79. 11 21
      apps/desktop/electron-builder.config.mjs
  80. 5 2
      apps/desktop/src/host-process.ts
  81. 4 2
      apps/desktop/src/main.ts
  82. 11 0
      apps/desktop/tests/host-process.spec.ts
  83. 15 61
      apps/desktop/tests/macos-signature.spec.ts
  84. 4 1
      apps/desktop/tests/main-startup.spec.ts
  85. 66 0
      apps/web/tests/diff-context.e2e.ts
  86. 45 20
      apps/web/tests/document-preview.e2e.ts
  87. 30 0
      apps/web/tests/expected/sidebar-terminal/colors.expected.md
  88. 3 0
      apps/web/tests/expected/sidebar-terminal/guide.expected.md
  89. 0 3
      apps/web/tests/expected/sidebar-terminal/selection.expected.md
  90. 3 3
      apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md
  91. 14 0
      apps/web/tests/expected/sidebar-terminal/theme.expected.md
  92. 41 5
      apps/web/tests/lifecycle-chrome.e2e.ts
  93. 23 15
      apps/web/tests/scaffold.ts
  94. 201 12
      apps/web/tests/seeded-history.e2e.ts
  95. 5 3
      apps/web/tests/shipped-composition.e2e.ts
  96. 176 21
      apps/web/tests/sidebar-terminal.e2e.ts
  97. 12 3
      apps/web/tests/turn-tail-actions.e2e.ts
  98. 1 0
      apps/web/tsconfig.json
  99. 3 0
      benchmarks/package.json
  100. 2 0
      benchmarks/terminal-io/session-adapter.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md
-2026-08-05-profile-plugin-bundles.md: 7e51345e7eba8a58db63807e31d4a11481e3ffea
-2026-08-05-profile-plugin-bundles.zh.md: b2631603737ea9412eb97029ff01d751d8084cec
+2026-08-05-profile-plugin-bundles.md: 48786a9c1ccaceb5f16c9eefed01e556cf759e6d
+2026-08-05-profile-plugin-bundles.zh.md: c88cbf98e4276549a6fae6a5b1253d3d838a64ca

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md

@@ -14,7 +14,7 @@ Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a
 
 The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
 
-Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
+Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md), which applies the same installation-first and ordered-bundle rules in memory; retained link and dual modes can materialize the same result.
 
 Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-host-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../../archived/feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing.
 
@@ -31,5 +31,5 @@ Two supporting refactors: the webserver's built-in static dist serving became th
 - New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape.
 - Users can start an independent custom profile from any shipped application template without copying machine-local profile state.
 - `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone.
-- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly.
+- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production and exercises the same profile package-selection rules, so composition drift between test and product fails loudly.
 - Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration; `$DSH_HOME/config.yaml` is ignored.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
 
-解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)
+解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md),在内存中应用相同的安装优先与有序 bundle 规则;保留的 link 与 dual 模式可以物化同一结果
 
 两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-host-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../../archived/feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与响亮失败的解析。
 
@@ -31,5 +31,5 @@ Status: implemented
 - 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。
 - 用户可以从任意随附应用模板启动一个独立的自定义 profile,而不会复制机器本地的 profile 状态。
 - `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。
-- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。
+- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,并执行相同的 profile 包选择规则,因此测试与产品之间的组合漂移会响亮失败。
 - 按发布前姿态,后端不携带旧磁盘配置的兼容行为;`$DSH_HOME/config.yaml` 会被忽略。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.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-18-experimental-agent-teams-packages.md
-2026-08-18-experimental-agent-teams-packages.md: 2a00b651e0073434a5a68df13b9716adcab5fccf
-2026-08-18-experimental-agent-teams-packages.zh.md: df73ee5c05fd3a25bf843bee10b06535a1512783
+2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9
+2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md

@@ -20,7 +20,7 @@ The generic caller-reserved continuable child identity and selective direct-chil
 
 The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
 
-Profile installation resolves each published bundle and its dependencies through the profile's package manager. The generic profile launcher then applies the selected layers without adding them to any shipped profile or changing another profile's resolution.
+Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
 
 Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md

@@ -20,7 +20,7 @@ dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams
 
 公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。
 
-profile 安装通过自身 package manager 解析每个公开 bundle 及其依赖。通用 profile launcher 随后应用所选层,不会把它们加入任何随附 profile,也不会改变其他 profile 的解析结果。
+profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
 
 对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.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-21-deepseek-llm-api-request-extensions.md
-2026-08-21-deepseek-llm-api-request-extensions.md: a0e38c1da5af32b0ba5e07df632ac4e25148419e
-2026-08-21-deepseek-llm-api-request-extensions.zh.md: 763f418c7bd6029c08027cd5f4705bffd984b9ee
+2026-08-21-deepseek-llm-api-request-extensions.md: 7a3609a3f7d8b2aa6edb634643ef234b7e3e8c07
+2026-08-21-deepseek-llm-api-request-extensions.zh.md: 69426df3cc85851e448b98dc51af79555b8cbafc

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md

@@ -14,13 +14,13 @@ Both values belong only on the official DeepSeek adapter path. Adding them to `G
 
 ## Decision
 
-`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: package metadata is enabled by default, while Session-log upload is disabled by default and requires `session-log-deepseek.enabled: true`. Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
+`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
 
 The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call.
 
 ## Incremental session-log field
 
-`@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log` as an explicit opt-in. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
+`@deepseek-ai/dsh-session-log-deepseek` owns the default-on `dsh_session_log` field. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
 
 The failure direction is at least once. A transport or provider rejection records no watermark. A crash after remote acceptance but before the watermark persists causes replay after resume, never a skipped sequence. Existing session checkpoints persist the event; the upload plugin owns no second store.
 
@@ -50,7 +50,7 @@ The process-lifetime manifest-identity cache remains separate because in-process
 
 ## Verification
 
-Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin the default-off policy, explicit full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, out-of-order acceptance, and late invariant loading. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata plus opt-in Session upload, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests.
+Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin default-on and explicit-off policies, full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, out-of-order acceptance, and late invariant loading. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata and Session upload plus explicit upload disablement, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests.
 
 ## Alternatives considered
 
@@ -85,7 +85,7 @@ About 98% of the measured v1 real-session events were `assistant/chunk`. Omittin
 
 ## Consequences
 
-Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. An explicit Session-log opt-in also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata.
+Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata.
 
 The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available.
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md

@@ -14,13 +14,13 @@ Status: implemented
 
 ## 决策
 
-`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据默认开启,会话日志上传默认关闭,需要设置 `session-log-deepseek.enabled: true`。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
+`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
 
 提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
 
 ## 增量会话日志字段
 
-`@deepseek-ai/dsh-session-log-deepseek` 以显式选择启用的方式拥有 `dsh_session_log`。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
+`@deepseek-ai/dsh-session-log-deepseek` 拥有默认开启的 `dsh_session_log` 字段。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
 
 失败方向为至少一次。传输失败或提供方拒绝不会记录水位。远端接受后、水位持久化前发生崩溃,会在恢复后触发重放,绝不会跳过序列。现有会话检查点会持久化该事件;上传插件不拥有第二份存储。
 
@@ -50,7 +50,7 @@ Status: implemented
 
 ## 验证
 
-注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。会话测试固定默认关闭策略、显式启用后的首次完整/后续后缀交付、与基础正文消息无关的直接完整事件信封、增量水位 fold、持久化重启恢复、fork 身份围栏、乱序接受与 invariant 延迟加载。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放会固定 2xx 后扩展接受,TypeScript JSON-RPC 与 Python 打包运行时快照则通过两套 SDK 投影接受事件。真实 Loader 组合会固定默认包元数据与显式启用的会话上传,一个真实 API 请求会挂载两个随附扩展并证明官方端点接受它们;pi-ai 测试保持其协议请求不变。
+注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。会话测试固定默认开启与显式关闭策略、首次完整/后续后缀交付、与基础正文消息无关的直接完整事件信封、增量水位 fold、持久化重启恢复、fork 身份围栏、乱序接受与 invariant 延迟加载。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放会固定 2xx 后扩展接受,TypeScript JSON-RPC 与 Python 打包运行时快照则通过两套 SDK 投影接受事件。真实 Loader 组合会固定默认包元数据、默认会话上传与显式关闭上传,一个真实 API 请求会挂载两个随附扩展并证明官方端点接受它们;pi-ai 测试保持其协议请求不变。
 
 ## 考虑过的替代方案
 
@@ -85,7 +85,7 @@ Status: implemented
 
 ## 后果
 
-DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。显式选择启用会话日志后,请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
+DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
 
 `delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
-2026-09-01-v2-embedded-assistant-streams.md: 219879600f8435a6ecc9593661dd3f463ed1158c
-2026-09-01-v2-embedded-assistant-streams.zh.md: d38c180b1f1de689620f2e0533d2b02f4a71e466
+2026-09-01-v2-embedded-assistant-streams.md: 89b494959fcc10a2c9847908adcf13d5eedcd749
+2026-09-01-v2-embedded-assistant-streams.zh.md: 63cb425740cd2243361ec00a6dadad82c38f6f0f

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md

@@ -31,7 +31,7 @@ The migration publication verifier and frozen v2 fixture validator require the e
 
 The Web follow adapter opts into these process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, and reopens follow on a revision gap. A committed end publishes a named settlement delta that removes the attempt's transient matches, adds the durable entry, and replays only affected Conversation Contexts; an abandoned end publishes the same delta without an entry. A reconnect baseline carries the active attempt's durable start cursor and compact prefix.
 
-The Client event source passes durable settlements through unchanged. The Chat and Trajectory Assistant nodes fold `assistant/live-chunk` while an attempt is active, build settled output directly from `assistant/message`, and do not replay an `assistant/attempt` stream for presentation. Cold settled presentation therefore does not reconstruct per-token timing; other consumers may expand the durable stream when they require its exact evidence.
+The Client event source passes durable settlements through unchanged. Chat and Trajectory fold `assistant/live-chunk` while an attempt is active and build settled output directly from `assistant/message`. Chat does not reconstruct first-token timing after settlement retires the transient chunks. Trajectory reads timing from the [compact stream records](2026-09-06-embedded-stream-record-readers.md) in `assistant/message` and `assistant/attempt`, including when opening history. Neither target expands settled streams into per-delta objects for presentation; other consumers may expand the durable stream when they require its exact evidence.
 
 ### Released v1 to v2 migration
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md

@@ -31,7 +31,7 @@ Migration publication verifier 与冻结的 v2 fixture validator 要求嵌入式
 
 Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,并在 revision 缺口时重新打开 follow。committed end 会发布具名 settlement delta,删除该 attempt 的 transient match、加入持久 entry,并只重放受影响的 Conversation Context;abandoned end 会发布不含 entry 的同类 delta。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。
 
-Client event source 原样传递持久 settlement。Chat 与 Trajectory 的 Assistant node 在 attempt 活跃期间折叠 `assistant/live-chunk`,直接从 `assistant/message` 构建 settled output,并且不为展示重放 `assistant/attempt` stream。因此冷恢复的 settled presentation 不会重建逐 token timing;其他消费方需要精确证据时仍可展开持久 stream。
+Client event source 原样传递持久 settlement。Chat 与 Trajectory 在 attempt 活跃时折叠 `assistant/live-chunk`,直接从 `assistant/message` 构造 settled output。结算移除临时 chunk 后,Chat 不会重建首 token 计时。Trajectory 从 `assistant/message` 与 `assistant/attempt` 中的[紧凑流记录](2026-09-06-embedded-stream-record-readers.zh.md)读取计时,包括打开历史时。两个目标都不会为展示将已结算流展开为逐 delta 对象;其他消费方需要精确证据时仍可展开持久 stream。
 
 ### 已发布 v1 到 v2 迁移
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md
-2026-09-05-canonical-feedback-log.md: c890064817ac0604fa4cc2b4073850174195e511
-2026-09-05-canonical-feedback-log.zh.md: e7e8563df8ad37d7aba641e58da4fc8872ec10b9
+2026-09-05-canonical-feedback-log.md: ce7b705b24cb0a4f7432215e19589095a9afc719
+2026-09-05-canonical-feedback-log.zh.md: 7d970ff7aea4962ea52c764fec5b6c07e3669099

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md

@@ -14,7 +14,7 @@ The canonical Session log owns feedback. Session-level remarks use `feedback/rec
 
 Live message-feedback mutations append through the owning Session and await its durability checkpoint; cold mutations hold a persistence write handle across read, comparison, append, and flush without creating a Session or Agent. A matching no-op appends nothing but still awaits persistence. Failures propagate, and a failed live flush can leave an observable in-memory item for retry. Per-item versions prevent unrelated message edits from conflicting; strict stale-write rejection prevents ABA overwrites even when the desired value matches. Target validation binds a judgment to a sent assistant message, and forks keep independent judgments. These choices retain rationale recorded in the [archived sidecar decision](../../archived/architecture/2026-08-10-message-feedback-sidecar.md), whose storage and commit mechanism is superseded.
 
-The existing opt-in [session-log-deepseek contribution](../../../../packages/session/session-log-deepseek/README.md) includes feedback in the ordinary `dsh_session_log` suffix on a subsequent eligible request. It uses the existing DeepSeek destination selection and acceptance watermark. There is no separate `dsh_feedback` uploader, feedback-triggered LLM request, or model-input field. The [explicit-feedback OTel decision](2026-09-05-nonofficial-feedback-otel.md) owns the independent feedback-triggered upload for all users and providers.
+The existing default-on [session-log-deepseek contribution](../../../../packages/session/session-log-deepseek/README.md) includes feedback in the ordinary `dsh_session_log` suffix on a subsequent eligible request. It uses the existing DeepSeek destination selection and acceptance watermark. There is no separate `dsh_feedback` uploader, feedback-triggered LLM request, or model-input field. The [explicit-feedback OTel decision](2026-09-05-nonofficial-feedback-otel.md) owns the independent feedback-triggered upload for all users and providers.
 
 The command confirms recording with the Session and anonymous user ids, without depending on telemetry or disclosing its policy. Its append remains unflushed. This supersedes the command-copy decision in the [archived sharing disclosure note](../../archived/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md). The [telemetry service's policy API](../../../../packages/session/session-telemetry/README.md#the-sharing-disclosure) remains independently available: a backend discloses its policy, not delivery or retention, and the optional OTel package does not own that vocabulary.
 
@@ -24,7 +24,7 @@ The command confirms recording with the Session and anonymous user ids, without
 
 **Reuse `feedback/record` for message edits.** A free-text Session remark does not identify an item mutation. Distinct events preserve message identity and deletion semantics; upload policy remains consumer-owned.
 
-**Add a dedicated feedback uploader or immediate LLM request.** The opt-in log contribution carries canonical events on eligible requests. The existing OTel pipeline independently handles explicit-feedback uploads for all providers, without a custom feedback uploader or another model request.
+**Add a dedicated feedback uploader or immediate LLM request.** The default-on log contribution carries canonical events on eligible requests. The existing OTel pipeline independently handles explicit-feedback uploads for all providers, without a custom feedback uploader or another model request.
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 live 消息反馈变更通过所属 Session 追加,并等待其持久化检查点;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄,不创建 Session 或 Agent。匹配版本的无变更操作不追加事件,但仍等待持久化。故障会原样传播,live flush 失败可能留下可观测的内存条目以供重试。逐条版本避免不同消息的编辑互相冲突;严格拒绝陈旧写入避免 ABA 覆盖,即使期望值已经匹配也不例外。目标校验把判断绑定到已发送的 assistant 消息,fork 保持独立判断。这些选择保留[已归档伴随记录决策](../../archived/architecture/2026-08-10-message-feedback-sidecar.md)记载的理由,但其存储与提交机制已被取代。
 
-现有需显式启用的 [session-log-deepseek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)会在后续符合条件的请求中,把反馈纳入普通 `dsh_session_log` 后缀。它使用现有的 DeepSeek 目标选择和接受水位。没有独立的 `dsh_feedback` 上传器、反馈触发的 LLM 请求或模型输入字段。[显式反馈 OTel 决策](2026-09-05-nonofficial-feedback-otel.zh.md)负责面向所有用户和提供方的独立反馈触发上传。
+现有默认开启的 [session-log-deepseek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)会在后续符合条件的请求中,把反馈纳入普通 `dsh_session_log` 后缀。它使用现有的 DeepSeek 目标选择和接受水位。没有独立的 `dsh_feedback` 上传器、反馈触发的 LLM 请求或模型输入字段。[显式反馈 OTel 决策](2026-09-05-nonofficial-feedback-otel.zh.md)负责面向所有用户和提供方的独立反馈触发上传。
 
 命令用 Session 与匿名用户 id 确认记录,不依赖遥测,也不披露其策略。其追加仍不执行 flush。这取代[已归档共享披露记录](../../archived/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md)中的命令文案决策。[遥测服务的策略 API](../../../../packages/session/session-telemetry/README.zh.md#the-sharing-disclosure) 仍可独立使用:后端披露策略,而不保证投递或保留,可选 OTel 包不拥有这套词汇。
 
@@ -24,7 +24,7 @@ live 消息反馈变更通过所属 Session 追加,并等待其持久化检查
 
 **对消息编辑复用 `feedback/record`。** 自由文本的 Session 备注不能标识条目变更。独立事件保留消息身份和删除语义;上传策略仍由消费方负责。
 
-**增加专用反馈上传器或立即发起 LLM 请求。** 需显式启用的日志贡献在符合条件的请求上传送权威事件。现有 OTel 流水线独立处理所有提供方的显式反馈上传,无需自定义反馈上传器或另一个模型请求。
+**增加专用反馈上传器或立即发起 LLM 请求。** 默认开启的日志贡献在符合条件的请求上传送权威事件。现有 OTel 流水线独立处理所有提供方的显式反馈上传,无需自定义反馈上传器或另一个模型请求。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.md
-2026-09-05-nonofficial-feedback-otel.md: bbc947455f7a84a41af6651223eb62129fa8d452
-2026-09-05-nonofficial-feedback-otel.zh.md: de9b0322861118cdc1163a0faefb172fc1062776
+2026-09-05-nonofficial-feedback-otel.md: 8225d8bea030c4f09b60fd1a228f0425e9489d39
+2026-09-05-nonofficial-feedback-otel.zh.md: e8ea48b3fe2f228ef57ae1a20f92100dfc333f9c

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.md

@@ -16,7 +16,7 @@ An authorized prefix includes all unhanded canonical context from seq 0 through
 
 The backend uses on-demand capture with complete history and the existing redaction waterfall. `DISABLED` constructs no transport. `FULL` is rejected rather than aliased. Direct `ctx.sessionTelemetry.emit()` calls are no-ops, so callers cannot bypass feedback authorization. SDK scheduled flush and shutdown may finish previously authorized batches but never capture new records. Sending after submission needs no further user interaction or model call.
 
-The [canonical-feedback decision](2026-09-05-canonical-feedback-log.md) owns storage, versions, deletion, and plain command confirmation. The [opt-in DeepSeek contribution](../../../../packages/session/session-log-deepseek/README.md) remains independent, with its existing destination and acceptance behavior.
+The [canonical-feedback decision](2026-09-05-canonical-feedback-log.md) owns storage, versions, deletion, and plain command confirmation. The [default-on DeepSeek contribution](../../../../packages/session/session-log-deepseek/README.md) remains independent, with its existing destination and acceptance behavior.
 
 ## Alternatives considered
 
@@ -28,6 +28,6 @@ The [canonical-feedback decision](2026-09-05-canonical-feedback-log.md) owns sto
 
 ## Consequences
 
-Handoff is best-effort, not collector acceptance. Same-object cursors suppress repeated capture, but fresh cold snapshots and new feedback after restart can repeat prefixes; receivers deduplicate on `(session.id, session.format_version, event.seq)`. There is no durable OTel outbox, delivery watermark, or harness HTTP retry promise. SDK batching and loss behavior apply after enqueue. OTel and the opt-in DeepSeek path can overlap. Withdrawal exports a deletion event, not remote erasure.
+Handoff is best-effort, not collector acceptance. Same-object cursors suppress repeated capture, but fresh cold snapshots and new feedback after restart can repeat prefixes; receivers deduplicate on `(session.id, session.format_version, event.seq)`. There is no durable OTel outbox, delivery watermark, or harness HTTP retry promise. SDK batching and loss behavior apply after enqueue. OTel and the default-on DeepSeek path can overlap. Withdrawal exports a deletion event, not remote erasure.
 
 [OTel tests](../../../../packages/session/session-telemetry-otel/tests/otel.spec.ts) cover explicit-feedback capture, provider-independent behavior, lifecycle silence, fork consent, cold commits, and direct-call denial. [Coordinator tests](../../../../packages/session/session-telemetry/tests/telemetry.spec.ts) cover history capture; [base tests](../../../../packages/bundle/base/tests/base.spec.ts) pin the mounted default.

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 后端使用包含完整历史的按需捕获与现有脱敏 waterfall(瀑布式事件)。`DISABLED` 不构造传输。`FULL` 被拒绝,不作为别名。直接调用 `ctx.sessionTelemetry.emit()` 是空操作,因此调用方不能绕过反馈授权。SDK 定时刷新和关闭可以完成先前已授权的批次,但绝不捕获新记录。提交后的发送无需进一步用户交互或模型调用。
 
-[权威反馈决策](2026-09-05-canonical-feedback-log.zh.md)负责存储、版本、删除与纯命令确认。[需主动开启的 DeepSeek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)保持独立,保留现有目标与接受行为。
+[权威反馈决策](2026-09-05-canonical-feedback-log.zh.md)负责存储、版本、删除与纯命令确认。[默认开启的 DeepSeek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)保持独立,保留现有目标与接受行为。
 
 ## 考虑过的替代方案
 
@@ -28,6 +28,6 @@ Status: implemented
 
 ## 后果
 
-交接尽力而为,不代表采集端接受。同对象游标抑制重复捕获,但新冷快照和重启后的新反馈可能重复前缀;接收方按 `(session.id, session.format_version, event.seq)` 去重。没有持久化 OTel outbox、投递水位或 harness HTTP 重试承诺。入队后适用 SDK 批处理与丢失行为。OTel 与需主动开启的 DeepSeek 路径可能重叠。撤回导出删除事件,不是远端擦除。
+交接尽力而为,不代表采集端接受。同对象游标抑制重复捕获,但新冷快照和重启后的新反馈可能重复前缀;接收方按 `(session.id, session.format_version, event.seq)` 去重。没有持久化 OTel outbox、投递水位或 harness HTTP 重试承诺。入队后适用 SDK 批处理与丢失行为。OTel 与默认开启的 DeepSeek 路径可能重叠。撤回导出删除事件,不是远端擦除。
 
 [OTel 测试](../../../../packages/session/session-telemetry-otel/tests/otel.spec.ts)覆盖显式反馈捕获、提供方无关行为、生命周期静默、fork 同意、冷会话提交与直接调用拒绝。[协调器测试](../../../../packages/session/session-telemetry/tests/telemetry.spec.ts)覆盖历史捕获;[基础配置测试](../../../../packages/bundle/base/tests/base.spec.ts)固定挂载默认值。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
-2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5
-2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c
+2026-09-06-embedded-stream-record-readers.md: efad86a4f940bf3b56d4b141a8fe1e3659c8dd9c
+2026-09-06-embedded-stream-record-readers.zh.md: f7131a27171163954bca8b7013dc6d90f907c552

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md

@@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 
-Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
+Session Stats and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Trajectory settles content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. Chat follows its [settled-reply timing policy](../bug-fix/2026-09-14-chat-presentation-defaults.md). The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
 
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md

@@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]`
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 
-Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
+Session Stats 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。Chat 遵循[已结算回复的计时策略](../bug-fix/2026-09-14-chat-presentation-defaults.zh.md)。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
 
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93
-2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714
+2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793
+2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc
 
 Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
 
-Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
 ## Alternatives considered
 
@@ -38,4 +38,4 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H
 
 ## Consequences
 
-Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering.
+Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, width-fitted raster and SVG rendering, inert SVG scripts, and lazy continuous PDF Worker rendering.

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md

@@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
-Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
 
 ## 考虑过的替代方案
 
@@ -38,4 +38,4 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、P
 
 ## 影响
 
-替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、按宽度适配的位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.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-profile-resolution-generations.md
+2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5
+2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f

+ 118 - 0
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md

@@ -0,0 +1,118 @@
+# Agent Note: Add immutable profile resolution generations
+
+Status: implemented
+
+English | [中文](2026-09-09-profile-resolution-generations.zh.md)
+
+## Problem
+
+A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
+
+The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
+
+## Decision
+
+Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to link mode and preserves the existing materialized lookup behavior. Internal callers and tests can select runtime mode, which installs the generation into Node's ESM and CommonJS resolvers, or dual mode, which materializes and verifies the same generation. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
+
+### One selection algorithm
+
+The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
+
+The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
+
+Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings.
+
+The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. The launcher uses it by default. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison.
+
+### Immutable generations
+
+A resolver registration holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
+
+Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible as it does in link mode. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
+
+The launcher constructs one startup generation. The service accepts an additive successor, but no package-manager transaction invokes replacement in this implementation.
+
+### Shared ESM and CommonJS rule
+
+The resolver uses `node-addon-require-builtin` to read `internal/modules/esm/loader` and `internal/modules/cjs/loader`. The ESM adapter wraps the per-thread singleton `CascadedLoader` resolve methods. The CommonJS adapter wraps the internal builtin's `Module._resolveFilename`; that `Module` is the same object exported by `node:module`.
+
+Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, generation, and after-fallback package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses a generation hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
+
+The adapters call the captured native resolver after routing. Node remains responsible for exports, import and require conditions, main files, subpaths, extensions, native caches, and error codes. Routed ESM failures replace the internal lookup anchor in Node's diagnostic with the original importer. A selected package's invalid export or missing target does not trigger another same-name candidate. CommonJS does not replace `_findPath` or reproduce `_resolveFilename`.
+
+The guarantee covers Node's default `import`, `import()`, `import.meta.resolve`, `require`, and `require.resolve` after installation in that thread. It does not cover already linked modules, custom `vm` linkers, opaque non-Node importers, or third-party Workers.
+
+### Active plugin list and package metadata
+
+The resolution generation lists available fallback packages; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed generation is authoritative, including a miss; a service created without a generation retains native lookup for low-level embedders.
+
+The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. A repeated query uses the same generation and therefore cannot drift from the route used for the import. Non-Node importers that need package metadata must explicitly implement the same deterministic resolver interface.
+
+The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread resolver and Worker-generation lifetimes; `resolver.ts` implements generation lookup and the Node Internal adapters; `worker-bootstrap.ts` installs an inherited generation in one thread. Existing profile selection and disk materialization remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
+
+The service definition and provider remain together in `app-boot` because profile boot owns the resolver lifetime. Extracting a separate capability seam becomes warranted when a launcher-independent provider or independently evolving consumers require it.
+
+### Workers and generation updates
+
+The main thread publishes a structured-clone representation of the current generation through Worker environment data. Each built Harness-owned Worker uses its build banner to obtain its own ESM and CommonJS internal objects and install the same adapters without traversing manifests. The bootstrap bundle has no static package imports. Source Worker entries retain their existing self-contained dependencies. Third-party Workers remain unchanged.
+
+New Workers inherit the latest published generation. Existing Workers keep the generation they inherited, so a caller that publishes a successor must restart them. The ESM bootstrap cannot affect static dependencies linked before its execution, so Worker bundles keep pre-bootstrap static imports natively resolvable and start code needing the profile resolver through a later dynamic import.
+
+### Additive package changes
+
+A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of an existing package. The caller publishes an additive successor before mounting the new Loader row; this implementation does not provide that package transaction. A mount failure may leave the package installed but inactive.
+
+Replacing, upgrading, or removing an already loaded package requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Generation replacement does not claim to unload modules.
+
+### Disk migration
+
+Runtime-only launch paths do not create, update, or retire symlinks and proxy packages. The resolver treats the legacy shared fallback and `.dsh-module-fallback` projections as virtual insertion positions: a generation hit uses the table target, while a miss skips those old positions before continuing native ancestor lookup. During a dual phase, the launcher materializes and installs the same generation; tests disable each backend in turn and compare their targets.
+
+Legacy disk state remains available to link-only launches, old processes, and rollback without participating in runtime-only selection. Removing that state is a separate maintenance operation outside this change.
+
+### Mode behavior
+
+Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
+
+The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly.
+
+Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
+
+Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links.
+
+### Performance and verification
+
+Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. Ordinary hot paths consist of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and one native resolution; a cache hit returns the generation-owned result directly. A CommonJS request may perform one native probe followed by one routed resolution when a local package directory exists without `exports` but lacks the requested subpath. Out-of-scope calls do not read manifests and cache only whether each parent belongs to the profile scope.
+
+One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost.
+
+Behavior tests compare the runtime generation with the disk materializer over the same package trees, then exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, and explicit CommonJS options. The Node compatibility matrix runs the resolver, service, and bootstrap specifications across the supported internal-loader variants. Worker tests verify environment-data publication and bootstrap installation with mocked thread and native-loader interfaces; they do not launch a built Worker. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
+
+## Alternatives considered
+
+**Keep disk projections permanently.** This preserves native lookup without process hooks, but retains cross-process mutation, stale generations, proxy manifests, writer locks, and packaged-runtime divergence. A bounded dual migration remains useful because both backends consume the same generation.
+
+**Expand the dependency graph lazily during resolve.** This spreads manifest reads and errors across first-use calls, changes timing from the disk implementation, complicates Worker startup, and makes the hot path depend on graph size. Complete generation construction is easier to compare and replace atomically.
+
+**Use `module.registerHooks`.** The public API puts every relevant resolution through Node's global hook dispatch before profile scope can reject it. Direct access to the existing internal ESM and CommonJS resolver objects permits a smaller fast path while retaining Node as the final resolver.
+
+**Record each Entry's actual import through Loader and HMR adapters.** Actual import records support stateful resolvers that return different targets for identical inputs. This design instead makes the generation authoritative and deterministic, so those records duplicate the resolver's answer while adding Entry, fiber, registry, ModuleJob, and HMR lifecycle state.
+
+**Mutate one long-lived table after each package operation.** Incremental mutation exposes partial graphs and requires targeted cache invalidation. Building a complete successor makes failure atomic and keeps all caches generation-owned.
+
+**Hot-replace already loaded package versions.** A resolution-table swap cannot invalidate every live module instance or object reference. Restart preserves one package identity per process.
+
+## Verification
+
+- One eager computation supplies the retained disk materializer and runtime generation.
+- Link-only, dual, and runtime-only tests consume the same generation; runtime startup neither writes nor retires module-resolution data.
+- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
+- ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
+- Production metadata lookup does not record Loader import results or wrap Entry, registry, tree, or HMR methods.
+- The Node compatibility matrix runs main-thread resolver specifications across supported loader interfaces; service and bootstrap specifications cover Worker environment-data and installation interfaces without launching a built Worker.
+- One-off built plain-Node measurements produced the hot and cold observations above against no-hook Node; the script and results are not committed evidence.
+- Package READMEs, architecture references, generated catalogs, and the bilingual pair describe the shipped implementation.
+
+## Consequences
+
+Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.

+ 118 - 0
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md

@@ -0,0 +1,118 @@
+# Agent Note: 增加不可变 profile 解析代际
+
+Status: implemented
+
+[English](2026-09-09-profile-resolution-generations.md) | 中文
+
+## Problem
+
+profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
+
+运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
+
+## Decision
+
+profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 link 模式,保留现有的物化查找行为。内部调用方和测试可以选择 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以选择 dual 模式,同时物化并校验同一份 generation。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
+
+### 唯一选包算法
+
+包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
+
+安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
+
+profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。
+
+旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。launcher 默认调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。
+
+### 不可变 generation
+
+一个解析器 registration 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
+
+选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会像 link 模式一样变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
+
+launcher 只构造启动 generation。服务接受新增型后继 generation,但本实现没有包管理器事务调用替换操作。
+
+### ESM 与 CommonJS 共用规则
+
+resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader` 和 `internal/modules/cjs/loader`。ESM 适配器包装每线程单例 `CascadedLoader` 的 resolve 方法。CommonJS 适配器包装内部 builtin 导出的 `Module._resolveFilename`;该 `Module` 与 `node:module` 导出的对象相同。
+
+两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、generation 和 after-fallback 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 generation 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
+
+适配器完成路由后调用捕获的原生 resolver。exports、import/require conditions、main、subpath、扩展名、原生缓存和错误码仍归 Node 处理。路由后的 ESM 失败会把 Node 诊断中的内部查找锚点替换为原始 importer。选中包的无效 export 或缺失目标不会触发另一个同名候选。CommonJS 不替换 `_findPath`,也不复制 `_resolveFilename`。
+
+保证范围是当前线程安装后发生的 Node 默认 `import`、`import()`、`import.meta.resolve`、`require` 和 `require.resolve`。已经链接的模块、自定义 `vm` linker、不透明的非 Node importer 和第三方 Worker 不在透明保证范围。
+
+### 活动插件列表与包元数据
+
+resolution generation 列出可用 fallback 包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 generation 后,即使查询未命中也以 generation 为准;底层嵌入方只安装服务而不提供 generation 时,服务保留 Node 原生查找。
+
+解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。重复查询读取同一个 generation,因此不会偏离 import 使用的路线。需要包元数据的非 Node importer 必须显式实现同一个确定性 resolver 接口,不能把调用来源推断重新引入 Node 主路径。
+
+实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程 resolver 与 Worker generation 的生命周期;`resolver.ts` 实现 generation 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 generation。旧 profile 选包和磁盘 materialize 逻辑留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
+
+服务定义与提供方继续放在 `app-boot`,因为 profile boot 拥有 resolver 生命周期。出现与 launcher 无关的提供方或需要独立演进的消费方时,再抽出单独的能力 seam。
+
+### Worker 与 generation 更新
+
+主线程通过 Worker environment data 发布当前 generation 的可结构化克隆表示和 profile scope。每个 Harness 自有 Worker 构建产物通过构建 banner 获取自己的 ESM/CJS Internal 并安装同一适配器,不重新遍历 manifest。bootstrap bundle 不静态导入任何包。源码 Worker 入口保持原有自包含依赖;第三方 Worker 保持不变。
+
+新 Worker 继承最新发布的 generation。已运行的 Worker 保留启动时继承的 generation,因此发布后继 generation 的调用方必须重启它们。ESM bootstrap 无法影响其执行前已链接的静态依赖,因此 Worker bundle 必须保证 bootstrap 之前的静态 import 可由原生 Node 解析,需要 profile resolver 的业务入口在 bootstrap 后通过 dynamic import 启动。
+
+### 只增加包的变更
+
+添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何既有 package name 的目录或版本。调用方先发布只增加映射的后继 generation,再挂载新的 Loader 配置项;本实现不提供该包事务。挂载失败可以留下已安装但未启用的包。
+
+替换、升级或删除已加载包需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。generation 换代不声称卸载模块。
+
+### 磁盘迁移
+
+runtime-only 启动流程不创建、更新或退休 symlink 和代理包。resolver 把旧共享 fallback 和 `.dsh-module-fallback` 投影视为虚拟插入位置:generation 命中时使用表中目标,未命中时越过旧位置继续原生祖先查找。dual 阶段物化并安装同一个 generation;测试分别禁用一个后端并比较目标。
+
+旧磁盘状态继续供 link-only 启动、旧进程和回滚使用,但不参与 runtime-only 选择。清理旧链接是本次变更之外的独立维护操作。
+
+### 模式行为
+
+link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
+
+普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。
+
+runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
+
+pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。
+
+### 性能与验证
+
+generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。普通热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和一次原生解析;缓存命中直接返回 generation 级结果。当本地 CommonJS 包目录没有 `exports` 且仅缺少所请求 subpath 时,一次请求可能先执行一次原生探测,再执行一次路由解析。作用域外调用不读取 manifest,只缓存 parent 是否位于 profile scope。
+
+实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
+
+行为测试在同一包树上比较运行时 generation 与磁盘 materializer,再覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions 和显式 CommonJS options。Node 兼容矩阵会在受支持的内部 loader 变体上运行 resolver、service 和 bootstrap 规格。Worker 测试通过 mock 线程与 native loader 接口验证 environment data 发布和 bootstrap 安装,但不会启动构建后的 Worker。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
+
+## Alternatives considered
+
+**永久保留磁盘投影。** 这能在没有进程 hook 时沿用原生查找,但仍有跨进程写入、陈旧 generation、代理 manifest、写锁和打包运行时差异。迁移期 dual 模式仍有价值,因为两个后端消费同一个 generation。
+
+**在 resolve 时惰性扩展依赖图。** 这会把 manifest 读取和错误分散到首次使用,改变磁盘实现的时机,使 Worker 启动更复杂,并让热路径成本随依赖图变化。完整构造 generation 更容易比较和原子替换。
+
+**使用 `module.registerHooks`。** 公共 API 会在 profile scope 拒绝请求之前让相关解析进入 Node 的全局 hook 分发。直接访问已有 ESM/CJS 内部解析器可以保留更小的快速路径,并继续让 Node 完成最终解析。
+
+**通过 Loader 和 HMR 适配器记录每个 Entry 的实际 import。** 实际 import 记录能支持相同输入返回不同目标的有状态 resolver。本设计改为以 generation 作为确定性权威,因此这些记录只会复制 resolver 的答案,同时增加 Entry、fiber、registry、ModuleJob 和 HMR 生命周期状态。
+
+**每次包操作增量修改一张长期表。** 增量修改会暴露半成品依赖图,并要求定点失效缓存。完整构造下一代使失败保持原子,并让所有缓存随 generation 生命周期存在。
+
+**热替换已经加载的包版本。** 解析表换代无法使所有存活模块实例和对象引用失效。重启可以保证每个进程只使用一个 package identity。
+
+## Verification
+
+- 一次 eager 计算同时供应保留的磁盘 materializer 和运行时 generation。
+- link-only、dual 和 runtime-only 测试消费同一个 generation;runtime 启动既不写入也不退休模块解析数据。
+- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
+- ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
+- 生产 package metadata 查询不记录 Loader import 结果,也不包装 Entry、registry、tree 或 HMR 方法。
+- Node 兼容矩阵会在受支持的 loader 接口上运行主线程 resolver 规格;service 和 bootstrap 规格覆盖 Worker environment data 与安装接口,但不会启动构建后的 Worker。
+- 一次性 plain Node 构建产物测量得到上述相对无 hook Node 的热路径和 cold 观察结果;脚本与结果并未提交为证据。
+- package README、架构引用、生成目录和双语文档对描述已交付实现。
+
+## Consequences
+
+runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.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-14-session-log-upload-default.md
+2026-09-14-session-log-upload-default.md: c1e7c9e050d1b1809ff3abba03fe4e17a3779daa
+2026-09-14-session-log-upload-default.zh.md: d7b6e64098eca3fe07c43272fcba18a3790182e2

+ 28 - 0
.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md

@@ -0,0 +1,28 @@
+# Agent Note: Default DeepSeek Session-log upload
+
+Status: implemented
+
+English | [中文](2026-09-14-session-log-upload-default.zh.md)
+## Problem
+
+Ordinary DeepSeek requests do not contain the complete canonical Session trajectory. Requiring each installation to enable log contribution prevents the default product configuration from supplying that trajectory. Recorded-session scenarios also need stable, explicit upload policies because acceptance events are part of their expected logs.
+
+## Decision
+
+`session-log-deepseek.Config.enabled` defaults to `true` in every process. An explicit `enabled: false` disables the contribution. The plugin does not inspect test-runner or snapshot environment variables.
+
+This supersedes only the opt-in default in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md); that note still owns field serialization, destinations, acceptance, and retry semantics. The headless and ACP corpus base patches and Web scaffold explicitly disable upload. Later scenario patches can enable it. The SDK text-turn recording omits the setting and exercises the shipped default, including durable acceptance events.
+
+## Alternatives considered
+
+**Derive the production default from test environment markers.** This also changes downstream SDK behavior when callers inherit those variables and makes ordinary tests exercise a different product default.
+
+**Refresh every recorded Session to include upload acceptance.** Explicit test composition preserves the existing scenarios while a default-configured SDK recording and Loader regression cover the default-on path.
+
+**Retain opt-in upload.** This does not supply the complete trajectory from ordinary product requests without installation-specific configuration.
+
+## Consequences
+
+Eligible requests send the complete unaccepted canonical log suffix, including message text, tool arguments and results, workspace paths, and feedback, to the resolved DeepSeek endpoint or configured gateway. No prompt tokens or model-visible content are added. Request bodies can grow substantially, and provider rejection still fails the request. OTel remains independent; disabling OTel does not disable this contribution.
+
+Configuration tests cover default-on and explicit overrides with and without test environment markers. Both DeepSeek protocol Loader cases observe the default request field and recorded acceptance watermark, and explicit-off cases observe their absence. Existing headless, ACP, Web, and SDK recordings validate their declared upload policies without rewriting committed Session generations.

+ 28 - 0
.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md

@@ -0,0 +1,28 @@
+# Agent Note: DeepSeek 会话日志默认上传
+
+Status: implemented
+
+[English](2026-09-14-session-log-upload-default.md) | 中文
+## 问题
+
+普通 DeepSeek 请求不包含完整的规范会话轨迹。要求每个安装环境启用日志贡献,会使默认产品配置无法提供该轨迹。录制会话场景也需要稳定、明确的上传策略,因为接受事件属于其期望日志。
+
+## 决策
+
+`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。显式设置 `enabled: false` 可关闭贡献。插件不读取测试运行器或快照环境变量。
+
+本决策仅取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的主动启用默认策略;该记录仍负责字段序列化、目的地址、接受与重试语义。headless 和 ACP 语料的基础 patch 以及 Web scaffold 显式关闭上传。后续场景 patch 可以启用上传。SDK text-turn 录制省略该设置,验证产品默认值及持久接受事件。
+
+## 考虑过的替代方案
+
+**根据测试环境标记决定生产默认值。** 调用者继承这些变量时,下游 SDK 行为也会改变,普通测试还会采用不同的产品默认值。
+
+**刷新所有录制会话以包含上传接受事件。** 显式测试配置保留现有场景,同时使用默认配置的 SDK 录制和 Loader 回归验证默认开启路径。
+
+**保留主动启用策略。** 没有安装环境专用配置时,该策略无法从普通产品请求提供完整轨迹。
+
+## 后果
+
+符合条件的请求会向解析后的 DeepSeek 端点或已配置网关发送完整的未接受规范日志后缀,包括消息文本、工具参数与结果、工作区路径和反馈。不增加提示词 token 或模型可见内容。请求正文可能显著增大,提供方拒绝仍会使请求失败。OTel 保持独立,关闭 OTel 不会关闭此贡献。
+
+配置测试覆盖有无测试环境标记时的默认开启与显式覆盖。两种 DeepSeek 协议的 Loader 用例观察默认请求字段和已记录的接受水位,显式关闭用例观察两者均不存在。现有 headless、ACP、Web 和 SDK 录制验证各自声明的上传策略,无需重写已提交的会话代际。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.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-11-incremental-terminal-retention.md
+2026-09-11-incremental-terminal-retention.md: 7e2507775bc39ed2599c2af1173f949bbf352145
+2026-09-11-incremental-terminal-retention.zh.md: 6c6fb95fa0c593ae2b7abb0007d5e7c6fc9f4e7d

+ 64 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md

@@ -0,0 +1,64 @@
+# Agent Note: incremental terminal retention
+
+Status: implemented
+
+English | [中文](2026-09-11-incremental-terminal-retention.zh.md)
+
+## Problem
+
+Persistent terminal output passes through scrollback and unread-send byte limits on every PTY callback. Rebuilding the entire retained string to enforce those limits makes callback cost grow with retained output. A 4 MiB scrollback window makes this repeated work substantial even when each incoming chunk is small.
+
+## Decision
+
+The private buffer in [terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) retains a linked sequence of strings, a head offset, and aggregate UTF-8 byte and newline counts. Appends inspect incoming text and evict only the oldest code points until both limits hold. Every evicted code point is charged to an earlier append, so total retention work is linear in input size. Reads assemble the retained strings; they remain proportional to retained output.
+
+The retained suffix matches line trimming followed by UTF-8 trimming. A trailing newline contributes an empty logical line. Truncation stays sticky until consumption clears the buffer. Adjacent surrogate halves across chunks count as one four-byte code point and are evicted together; unpaired halves retain JavaScript string identity and count as three UTF-8 bytes. The read-time `utf8Tail()` remains independent and unchanged.
+
+Every nonempty input is copied through UTF-16 before retention, preserving unpaired surrogates while detaching slices returned by the sanitizer from discarded control text. This applies to small pending fragments as well as large chunks. Private `truncated` and `isEmpty` getters let send settlement and startup polling inspect status without assembling scrollback.
+
+After at least half of the leading string is discarded, its suffix is copied to release the original backing storage. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor copies those fragments into one owned string. Large tails already own their storage and are not copied again at this point. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text.
+
+The [persistent PTY decision](../feature/2026-07-16-persistent-pty-sessions.md) continues to own session lifecycle, model-visible output, and retention semantics. This decision specializes storage and performance; it supersedes no active decision record.
+
+## Measurement design
+
+The terminal I/O benchmark drives `LocalPtySession` with a synthetic subprocess handle. Fixed 16 KiB ASCII chunks without newlines exercise the byte limit with a long logical line. Steady-state cases fill either a 128 KiB or 4 MiB window, then append the same additional 1 MiB. A separate empty-window case sends 5 MiB. The line limit is 10,000; unread output is limited to the smaller of 256 KiB and the scrollback capacity.
+
+Synchronous ingestion measures the send start and provider callbacks. Completion additionally waits for emulator processing and readiness, then reads the bounded terminal result. Retained heap is sampled after explicit GC while the session and returned output remain reachable. Built JavaScript runs under plain Node. These measurements exclude shell startup, operating-system PTY transport, model latency, and browser rendering.
+
+### Local reference measurements
+
+On Apple M5 Pro, macOS arm64, Node v26.5.0, five fresh workers per case compare the eager-retention baseline with incremental retention. The same worker and inputs measure both versions; only the private session implementation differs. Times below are milliseconds in sample order.
+
+| Case / metric | Eager retention samples | Incremental retention samples |
+|---|---|---|
+| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.84 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 89.339 ms (58.3×). Maximum retained heap for the large steady case rises from 4,512,656 to 6,219,296 bytes; this measures live session and result allocations together, not just buffer strings.
+
+A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,802,840 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict.
+
+The filtered-output memory case emits 513 callbacks of 64 KiB each, containing a complete 56 KiB OSC sequence followed by 8 KiB of visible text. This passes through the production sanitizer before filling a 4 MiB visible window and taking a bounded read. With incoming slices retained directly, the assertion fails at 36,706,592 bytes. Copying inputs into independent storage reduces retained heap to 7,635,440 bytes, below the unchanged 16 MiB limit. These measurements use Node v26.5.0 and fresh workers.
+
+A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 249.007 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip.
+
+The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all four cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build.
+
+## Alternatives considered
+
+**Cache only the byte count.** This leaves the per-append line split and full-string prefix deletion dependent on retained output. Both limits need incremental accounting.
+
+**Keep the complete output until a read.** This makes producer cost small but permits unbounded retention between reads. The configured limits apply during production.
+
+**Retain one node per callback.** Tiny callbacks make node metadata much larger than the bounded text. Bounded tail aggregation keeps node count tied to stored text blocks.
+
+**Store encoded UTF-8 chunks.** Encoding replaces unpaired UTF-16 surrogates. String chunks preserve the existing buffer semantics without adding a second text representation.
+
+## Consequences
+
+Append-time work no longer depends on repeatedly scanning the retained window. Snapshot and consume still allocate a combined string. Retention adds linked nodes and a bounded collection of pending small fragments. Functional tests cover byte/line interactions, consumption, split surrogate pairs, and 4 MiB retention. Performance evidence complements these output assertions; a synthetic provider does not establish real-shell command latency.

+ 64 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md

@@ -0,0 +1,64 @@
+# Agent Note: 增量终端保留策略
+
+Status: implemented
+
+[English](2026-09-11-incremental-terminal-retention.md) | 中文
+
+## 问题
+
+持久终端输出在每次 PTY 回调中都受 scrollback 和未读发送输出的字节上限约束。如果每次执行这些限制都重建完整的保留字符串,回调成本就会随保留输出量增长。即使每个输入分片很小,4 MiB scrollback 窗口也会使这项重复工作产生显著开销。
+
+## 决策
+
+[terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) 的私有缓冲区保留字符串链表、头部偏移,以及 UTF-8 字节数与换行符数的汇总值。追加操作检查输入文本,并仅淘汰最旧的码点,直到两个限制都满足。每个被淘汰码点的成本可归于之前的追加操作,因此保留策略的总工作量与输入量呈线性关系。读取时拼接保留字符串,成本仍与保留输出量成正比。
+
+保留后缀与先按行数裁剪、再按 UTF-8 字节数裁剪的结果一致。末尾换行符贡献一个空逻辑行。截断标志保持为真,直到消费操作清空缓冲区。跨分片相邻的代理项两半按一个四字节码点计数,并共同淘汰;未配对代理项保留 JavaScript 字符串原值,按三个 UTF-8 字节计数。读取时的 `utf8Tail()` 保持独立且不变。
+
+每个非空输入都在保留前通过 UTF-16 复制,在保留未配对代理项的同时,使清理器返回的切片脱离已丢弃的控制文本。待处理的小片段与大分片都遵循这一规则。私有 `truncated` 和 `isEmpty` getter 让发送结算和启动轮询无需拼接 scrollback 即可检查状态。
+
+头部字符串至少一半被丢弃后,其后缀会被复制,以释放原始底层存储。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将这些片段复制为一个独立字符串。大尾块已经拥有独立存储,此处不会再次复制。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。
+
+[持久 PTY 决策](../feature/2026-07-16-persistent-pty-sessions.zh.md)继续负责会话生命周期、模型可见输出与保留语义。本决策细化存储与性能,不取代任何活跃决策记录。
+
+## 测量设计
+
+终端 I/O 基准通过合成子进程句柄驱动 `LocalPtySession`。固定的 16 KiB ASCII 分片不含换行符,以长逻辑行触发字节上限。稳态场景先填满 128 KiB 或 4 MiB 窗口,再追加同样的 1 MiB。独立的空窗口场景发送 5 MiB。行数上限为 10,000;未读输出上限为 256 KiB 与 scrollback 容量中的较小值。
+
+同步接收计时覆盖发送启动与提供方回调。完成计时还等待终端模拟器处理与就绪,再读取有界终端结果。保留堆在显式 GC 后采样,此时会话与返回输出仍可达。构建后的 JavaScript 在普通 Node 下运行。这些测量不含 shell 启动、操作系统 PTY 传输、模型延迟或浏览器渲染。
+
+### 本地参考测量
+
+在 Apple M5 Pro、macOS arm64、Node v26.5.0 上,每个场景用五个全新 worker 比较全量保留计算基线 与增量保留策略。两个版本使用相同 worker 和输入,仅私有会话实现不同。下表时间单位为毫秒,按采样顺序排列。
+
+| 场景 / 指标 | 全量保留计算样本 | 增量保留计算样本 |
+|---|---|---|
+| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.84。5 MiB 完成时间的中位数从 5211.802 ms 降至 89.339 ms(58.3×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 6,219,296 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。
+
+独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,802,840 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。
+
+过滤输出的内存场景发送 513 个 64 KiB 回调,每个含完整的 56 KiB OSC 序列及其后的 8 KiB 可见文本。数据经过生产清理器,填满 4 MiB 可见窗口后进行有界读取。直接保留输入切片时,断言以 36,706,592 字节失败。将输入复制到独立存储后,保留堆降至 7,635,440 字节,低于不变的 16 MiB 上限。这些测量使用 Node v26.5.0 和全新 worker。
+
+真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 249.007 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。
+
+[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的四个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。
+
+## 考虑过的替代方案
+
+**仅缓存字节数。** 这仍会使每次追加的按行拆分和完整字符串前缀删除依赖保留输出量。两个限制都需要增量计数。
+
+**在读取前保留全部输出。** 这使生产成本较小,但允许读取之间的保留量无限增长。配置的限制必须在产生输出时生效。
+
+**每次回调保留一个节点。** 微小回调会使节点元数据远大于有界文本。受限的尾部合并使节点数与存储的文本块相关。
+
+**保留编码后的 UTF-8 分片。** 编码会替换未配对 UTF-16 代理项。字符串分片无需引入第二种文本表示,即可保留现有缓冲区语义。
+
+## 影响
+
+追加工作不再依赖重复扫描保留窗口。Snapshot 和 consume 仍分配拼接后的字符串。保留策略额外维护链表节点与有界的待处理小片段集合。功能测试覆盖字节数与行数的交互、消费操作、跨分片代理对,以及 4 MiB 保留窗口。性能证据补充这些输出断言;合成提供方不能证明真实 shell 命令延迟。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.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-14-chat-presentation-defaults.md
+2026-09-14-chat-presentation-defaults.md: 6703a14e2ad108c4b43dd50692a44c159cf3ea5a
+2026-09-14-chat-presentation-defaults.zh.md: 67ca433339ecf6e44d3f74b6c3ff9e465f802b0b

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep Chat presentation independent of Trajectory inspection
+
+Status: implemented
+
+English | [中文](2026-09-14-chat-presentation-defaults.zh.md)
+
+## Problem
+
+Trajectory inspection benefits from exposing complete recorded reasoning. Applying that default to Chat expands the live transcript during reasoning and changes its height when an answer or Tool call arrives. The Trajectory inspection change also added historical first-token recovery to Chat without a separate Chat behavior decision.
+
+## Decision
+
+[Chat](../../../../packages/client/ui-chat/README.md#turn-process-folding) starts each reasoning row collapsed and retains the reader's manual disclosure choice through subsequent output and settlement. Settlement retires the observed live chunks and rebuilds Chat reply nodes from durable events without recovering first-token time from embedded streams. Consequently, completed-turn TTFT and decoding speed are absent after live settlement as well as after reopening history. Turn-level process folding remains independently owned.
+
+[Trajectory inspection](../feature/2026-09-09-ptc-trajectory-code-inspection.md) keeps its expanded reasoning default, recorded timing, JSON controls, and PTC code inspector. The [compact stream readers](../architecture/2026-09-06-embedded-stream-record-readers.md) remain available to Trajectory and other consumers. These decisions partially supersede the Chat presentation additions while preserving both notes' independent rationale.
+
+## Alternatives considered
+
+**Keep Chat's automatic expansion and historical timing recovery.** These change Chat behavior beyond the requested Trajectory inspection work. Reintroducing either requires a separate Chat product decision and its own verification.
+
+**Revert the entire inspection change.** That would remove the requested Trajectory behavior along with the unintended Chat changes.
+
+## Consequences
+
+Chat reasoning requires a click to inspect in full. Elapsed turn time and the independently projected Session Stats remain available. Assembler tests distinguish transient retirement at live settlement from reopening durable history; browser replay verifies the completed-turn timing dialog before and after reload. Component tests cover collapsed streaming and reasoning-only replies and manual disclosure across answer and Tool-call arrival. Trajectory tests retain its separate defaults and timing.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 保持 Chat 展示与 Trajectory 查看独立
+
+Status: implemented
+
+[English](2026-09-14-chat-presentation-defaults.md) | 中文
+
+## 问题
+
+Trajectory 查看适合直接展示完整的已记录思考。将该默认行为应用到 Chat 会在思考期间展开实时对话,并在回答或工具调用到来时改变其高度。Trajectory 查看改动还为 Chat 增加了历史首 token 计时恢复,却没有独立的 Chat 行为决策。
+
+## 决策
+
+[Chat](../../../../packages/client/ui-chat/README.zh.md#turn-process-folding) 的每个思考行初始都折叠,后续输出和结算保留用户手动选择的展开状态。结算会移除观测到的实时 chunk,并从持久事件重建 Chat 回复节点,不从内嵌流恢复首 token 时间。因此,实时结算后和重新打开历史后,已完成轮次都不显示 TTFT 和解码速度。轮次级过程折叠仍独立维护。
+
+[Trajectory 查看](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md) 保留思考默认展开、已记录计时、JSON 控件和 PTC 代码查看器。[紧凑流读取器](../architecture/2026-09-06-embedded-stream-record-readers.zh.md) 仍供 Trajectory 和其他消费方使用。这些决策部分取代了 Chat 展示增量,同时保留两份记录各自独立的理由。
+
+## 考虑过的替代方案
+
+**保留 Chat 自动展开和历史计时恢复。** 这些改变了所请求的 Trajectory 查看工作之外的 Chat 行为。重新引入任一行为都需要独立的 Chat 产品决策及相应验证。
+
+**撤回整个查看改动。** 这会在撤回意外 Chat 改动的同时移除所请求的 Trajectory 行为。
+
+## 影响
+
+Chat 思考需要点击才能查看全文。轮次总耗时和独立投影的 Session Stats 仍可用。组装器测试区分实时结算时移除临时数据与重新打开持久历史;浏览器回放验证重载前后的已完成轮次计时对话框。组件测试覆盖流式与仅思考回复的默认折叠,以及回答和工具调用到来时的手动展开状态。Trajectory 测试保留其独立默认值和计时。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.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-14-web-diff-context.md
+2026-09-14-web-diff-context.md: 072af0966689d475d7fdd1df83861fa847f3745f
+2026-09-14-web-diff-context.zh.md: cd165a443451fc5ba580078b1b431a2788671dbe

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.md

@@ -0,0 +1,33 @@
+# Agent Note: Web diff cards compare contextual content
+
+Status: implemented
+
+English | [中文](2026-09-14-web-diff-context.zh.md)
+
+## Problem
+
+Filesystem result metadata carries before/after fragments that include unchanged context. Treating each complete fragment as removed or added mislabels shared lines and inflates both card and collapsed-row totals.
+
+## Decision
+
+The Web primitive derives line patches with the maintained `diff` library, using `maxEditLength: 256`. Each exact change includes up to three neutral context lines on either side; distant changes use separate hunks and shared context contributes to neither total. Beyond 256 additions/deletions per fragment, search stops and the complete old/new fragments render as a coarse replacement, including shared lines in the display, copy, and counts. The card and `diffTotals` use this same deterministic derivation. This remains Client presentation under the [tool presentation ownership decision](../architecture/2026-08-23-client-derived-tool-presentation.md), without changing persisted metadata or public props.
+
+## Alternatives considered
+
+Unbounded comparison stalls collapsed summaries on heavily changed fragments. A deterministic edit-distance limit preserves exact sparse edits regardless of file length; a wall-clock timeout could make the summary and body choose different results under load. A coarse replacement sacrifices alignment above the limit while retaining every input line. Caching or asynchronous rendering adds ownership and invalidation work that the bounded comparison does not require. Extending durable metadata or maintaining a custom diff algorithm is unnecessary for this presentation behavior.
+
+## Measurement
+
+A local CPU diagnostic bundled the production `DiffBlock.tsx` entry with esbuild (`--bundle --platform=node --format=esm`) and timed `diffTotals` under Node 26.5.0 on macOS ARM64. Each fragment has 10,000 lines: unique indexed lines replaced completely, 100 evenly spaced replacements, or alternating repeated `old`/`shared` versus `new`/`shared` lines. Input construction and module loading are excluded; returned totals remain reachable. These are function timings, not browser paint or input latency, and carry no CI timing threshold.
+
+| Input | Unbounded milliseconds | Bounded search milliseconds |
+| --- | --- | --- |
+| Complete replacement | 6492.16, 6777.79, 6786.79 | 5.25, 4.66, 4.19 |
+| 100 sparse replacements | 8.19, 4.58, 4.01 | 7.58, 4.26, 4.13 |
+| Alternating repeated lines | 3383.13 | 10.26, 7.46, 5.52 |
+
+The bound admits all 100 sparse replacements unchanged. The 129-replacement regression fails without it because the unbounded implementation returns exact counts instead of the required complete-fragment fallback.
+
+## Consequences
+
+The browser build includes `diff`. The bound limits edit-graph search, not wall-clock duration: normalization, fallback rows, and copied output still scale with input length, and expanded cards also derive their rows separately. The content-line rule treats a final newline as a terminator. Regressions cover exact output at 256 edits, complete coarse output above the limit, a sparse edit in 10,000 lines, shared and distant context, repeated lines, copied prefixes, and summary/footer parity. Authored and borrowed Session snapshots cover coarse and exact browser cards respectively.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Web diff 卡片比较含上下文的内容
+
+Status: implemented
+
+[English](2026-09-14-web-diff-context.md) | 中文
+
+## Problem
+
+文件系统结果元数据携带的前后文本片段包含未改动的上下文。把完整片段分别当作删除和新增会错误标记共享行,并夸大卡片和折叠工具行的统计。
+
+## Decision
+
+Web 原语通过维护中的 `diff` 库生成行补丁,使用 `maxEditLength: 256`。每处精确改动两侧最多保留三行中性上下文;远距离改动分成独立 hunk,共享上下文不计入增删统计。每个片段的新增与删除行数超过 256 时,搜索停止,完整新旧片段按粗粒度替换呈现,共享行也计入显示、复制和统计。卡片与 `diffTotals` 使用同一确定性推导。这遵循[工具呈现归属决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md),仍属于 Client 呈现,不改变持久化元数据或公开 props。
+
+## Alternatives considered
+
+无上限比较会让大量改动片段的折叠摘要停顿。确定性的编辑距离上限能让稀疏编辑保持精确,不受文件长度影响;墙钟超时可能使摘要和正文在负载下选择不同结果。粗粒度替换在超过上限后放弃对齐,但保留全部输入行。缓存或异步渲染会增加归属和失效处理,而有界比较不需要这些机制。该呈现行为无需扩展持久化元数据或维护自定义 diff 算法。
+
+## Measurement
+
+本地 CPU 诊断用 esbuild(`--bundle --platform=node --format=esm`)打包生产 `DiffBlock.tsx` 入口,并在 macOS ARM64 的 Node 26.5.0 下计时 `diffTotals`。每个片段有一万行:全部替换带唯一索引的行、均匀分布的 100 处替换,或交替重复的 `old`/`shared` 与 `new`/`shared` 行。不计输入构造和模块加载;返回的统计值保持可达。这些是函数耗时,不是浏览器绘制或输入延迟,也没有作为 CI 时间阈值。
+
+| 输入 | 无上限毫秒数 | 有界搜索毫秒数 |
+| --- | --- | --- |
+| 全部替换 | 6492.16, 6777.79, 6786.79 | 5.25, 4.66, 4.19 |
+| 100 处稀疏替换 | 8.19, 4.58, 4.01 | 7.58, 4.26, 4.13 |
+| 交替重复行 | 3383.13 | 10.26, 7.46, 5.52 |
+
+上限允许全部 100 处稀疏替换保持精确。移除上限时,129 处替换回归会失败,因为无上限实现返回精确统计,而非要求的完整片段回退。
+
+## Consequences
+
+浏览器构建包含 `diff`。上限约束编辑图搜索,不约束墙钟时长:规范化、回退行及复制内容仍随输入长度增长,展开卡片也会单独推导正文行。内容行规则把末尾换行视为终止符。回归覆盖 256 次编辑时的精确输出、超过上限后的完整粗粒度输出、一万行中的稀疏编辑、共享和远距离上下文、重复行、复制前缀及摘要与底部统计一致性。编写和借用的 Session 快照分别覆盖粗粒度和精确的浏览器卡片。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.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-08-03-web-sticky-collapsible-headers.md
+2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52
+2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360

+ 41 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md

@@ -0,0 +1,41 @@
+# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling
+
+Status: implemented
+
+English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md)
+
+## Problem
+
+Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle.
+
+## Decision
+
+The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row.
+
+Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header.
+
+The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
+
+The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state.
+
+No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components.
+
+## Alternatives considered
+
+**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable.
+
+**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency.
+
+**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling.
+
+**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block.
+
+## Consequences
+
+The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets.
+
+## Testing
+
+The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration.
+
+The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls.

+ 41 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md

@@ -0,0 +1,41 @@
+# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住
+
+Status: implemented
+
+[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文
+
+## 问题
+
+会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。
+
+## 决策
+
+每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。
+
+这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。
+
+钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
+
+规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。
+
+不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。
+
+## 曾考虑的替代方案
+
+**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。
+
+**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。
+
+**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。
+
+**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。
+
+## 后果
+
+很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。
+
+## 测试
+
+单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。
+
+真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.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-09-ptc-trajectory-code-inspection.md
-2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b
-2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88
+2026-09-09-ptc-trajectory-code-inspection.md: 19779d20e8d08ce0ca9678ab6626765faa13cf65
+2026-09-09-ptc-trajectory-code-inspection.zh.md: 7cdb2321d61893e43426798d311847662450d289

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -16,6 +16,8 @@ The result view preserves recorded text and uses a tree only for complete JSON o
 
 The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
 
+Trajectory thinking opens by default and supports manual disclosure. [Chat disclosure defaults](../bug-fix/2026-09-14-chat-presentation-defaults.md) are a separate presentation decision.
+
 ## Alternatives considered
 
 **Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
@@ -26,4 +28,4 @@ The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlemen
 
 ## Consequences
 
-Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover manual disclosure and switching Trajectory records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -16,6 +16,8 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树
 
 [PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
 
+Trajectory 思考默认展开,并支持手动展开折叠。[Chat 展开默认值](../bug-fix/2026-09-14-chat-presentation-defaults.zh.md)是独立的展示决策。
+
 ## 考虑过的替代方案
 
 **把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
@@ -26,4 +28,4 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树
 
 ## 后果
 
-读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖手动展开折叠及 Trajectory 记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: ce1162a84d3f96336cbb217dbdfe62d12ac23280
-2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3
+2026-09-09-web-sidebar-terminal.md: 9b0c1916872c04611f94bf00f7057cce99671c68
+2026-09-09-web-sidebar-terminal.zh.md: 5bc25f80cf6d33e07d2cec1b48e1f4ca7f7d750c

+ 5 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -10,7 +10,11 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 ## Decision
 
-`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+Guide entries declare stable ids within their provider. A keyed `sidebar.right.tab.guide.entry` slot dispatches by the active provider id, so an extension replacing a builtin also controls its guide rendering. The sidebar owns card placement and the default fallback; provider components own their controls and read the enclosing tab through framework hooks. An entry slot allows a shell menu without replacing the entire guide or nesting an interactive button inside another button.
+
+The application theme supplies terminal default colors. The body reads resolved CSS tokens, and updates xterm only when those colors change. Public OSC parser observers retain indexed and default-color overrides separately from the DSH defaults; resets remove the corresponding override before restoring the current theme. Observers delegate queries and color handling to xterm. xterm's minimum contrast adjustment improves text legibility without remapping ANSI backgrounds. The DOM cursor reads the rendered cell background after each render and uses a contrasting fill through scoped CSS variables, so cursor movement never resets the palette. Browser checks cover indexed, true-color and inverse cells, light/dark switching, OSC retention and reset, and blinking cursor styles.
+
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 

+ 5 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -10,7 +10,11 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 决定
 
-`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+开始页入口声明 provider 内稳定的 id。keyed `sidebar.right.tab.guide.entry` slot 按当前生效的 provider id 分发,因此替代 builtin 的 extension 也控制对应入口的渲染。侧栏负责卡片排列和默认回退,provider 组件负责自己的控件,并通过框架 hook 读取所在标签页。入口 slot 可以承载 shell 菜单,无需替换整个开始页或把交互按钮嵌套在另一个按钮内。
+
+应用主题提供终端的默认颜色。终端正文读取解析后的 CSS 令牌,仅在颜色变化时更新 xterm。公开的 OSC 解析观察器将索引色和默认颜色覆盖与 DSH 默认值分开保存;重置命令先删除对应覆盖,再恢复当前主题。观察器将查询和颜色处理交给 xterm。xterm 的最小对比度调整改善文字可读性,同时不重新映射 ANSI 背景色。DOM 光标在每次渲染后读取单元格实际背景,通过局部 CSS 变量使用有足够对比度的填充色,因此光标移动不会重置调色板。浏览器检查覆盖索引色、真彩色、反色单元格、明暗主题切换、OSC 保留和重置,以及闪烁光标样式。
+
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.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-11-sidebar-document-preview-polish.md
+2026-09-11-sidebar-document-preview-polish.md: b78141881513167a08050afb287eb4d1bcb43995
+2026-09-11-sidebar-document-preview-polish.zh.md: 43016af0dff72ed09a19271535912ba9abd65d9c

+ 35 - 0
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md

@@ -0,0 +1,35 @@
+# Agent Note: Sidebar document preview polish
+
+Status: implemented
+
+English | [中文](2026-09-11-sidebar-document-preview-polish.zh.md)
+
+## Problem
+
+The Sidebar document preview accumulated several experience defects (issue #3974). Images rendered at their intrinsic CSS-pixel size, so a wide image overflowed the pane and forced horizontal scrolling. The viewer dropdown always appended the plain-text fallback, so bitmap and PDF files offered a "Plain text" choice whose result is unreadable bytes, and files with one real renderer still showed a control with nothing meaningful to switch to. Binary containers with no renderer at all (video, archives, office documents) fell into the plain-text reader and surfaced a read error instead of a designed empty state. Each renderer carried its own loading copy and position, so opening a file flashed through several differently worded, differently placed indicators. PDF pages sat inside a double inset that shrank every page below the pane's width. Switching sidebar tabs remounted the file tree at scroll top, losing the reader's place.
+
+## Decision
+
+**Image width fit.** The image frame follows the scroller's width with a 12px inset; the image itself carries `max-width: 100%` and an 8px corner radius, so a wider image scales down to the pane's width at its aspect ratio, a smaller image keeps its intrinsic size centred by auto margins, and a taller image scrolls vertically in the shared body. Height fitting was considered and dropped: it needs a fixed-height frame, and mixed portrait cases produced surprising layouts for no user request.
+
+**Binary viewer choices.** `DocumentPreviewDefinition` gains optional `binaryExtensions`, the suffixes among a renderer's `extensions` whose bytes are not readable text; `register` rejects an entry absent from `extensions`. The renderer owns this knowledge: the image body declares `png, jpg, jpeg, gif, webp, bmp, ico` and leaves `svg` out because SVG source is readable XML; the PDF body declares `pdf`. `binaryDocumentPath` in the registry module answers whether any registered definition declares a filename's suffix binary, and the preview owner skips the plain-text fallback for such files. The header renders the viewer menu only when at least two candidates exist; a single candidate shows no viewer control at all.
+
+**Unsupported empty state.** The owner-side list in `document/unviewable.ts` names binary container suffixes (video, audio, archives, office documents, executables, fonts, disk images, design formats) that no renderer claims. The owner consults it only when no implementation matches, so any renderer registration always wins; a matching file shows the path header, the file-type icon, and one `unsupportedFile` line, and never issues a read. An uncertain suffix stays out of the list and keeps the plain-text fallback; a text-claimed file whose bytes fail the reader's checks reports the same copy through `error.notText`.
+
+**Unified loading.** `LoadingIndicator` renders icon-only, carrying its label as `aria-label` with no visible text; every renderer's loading copy is the shared "Reading…". Every wait before content exists — the owner's first read, PDF parsing, HTML packaging, image decoding — centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. An unrendered PDF page holds its place as a static 3:4 placeholder block on the theme's skeleton token `--dsw-alias-bg-skeleton` with no spinner; the document-open wait stays the owner's centred read spinner rather than the first page's placeholder, because the read spinner precedes the placeholder and a handoff between them visibly jumps positions. Placeholders carry no shimmer animation, whose per-page cost outweighs its value.
+
+**PDF full-bleed and copy.** The PDF body and page insets are removed so pages fill the pane's width edge to edge; the image renderer keeps its own 12px inset. Chinese error and status lines across the preview dictionaries drop trailing full stops.
+
+**Files tree scroll restore.** `ui-sidebar-files` follows the document preview's own pattern: the tree store gains `scrollTop` with a `scrolled` action, the body tracks its scroll offset locally and commits it once, on unmount and only while the owner's signal is live, and a remount restores it in a layout effect. Loaded levels already outlive the body in the store, so the remounted tree lays out at full height before the offset re-lands.
+
+## Alternatives considered
+
+**A boolean `binary` flag per definition.** The image renderer covers both bitmaps and SVG under one registration, so binariness is a property of the suffix, not the renderer.
+
+**Filtering in the preview owner by a hardcoded suffix list for registered renderers.** The owner would duplicate knowledge each renderer already holds, and external renderers could not extend the set; the owner-side list exists only for suffixes no renderer claims.
+
+**A one-item static viewer label.** Rendering the single remaining candidate as a static name puts a control that offers no action in the header; it is noise, so the header renders nothing.
+
+## Consequences
+
+Images never scroll horizontally; the pane's width is the only layout input, so no zoom control was added. Bitmap and PDF tabs show no viewer control; SVG keeps the menu with the plain-text choice; unclaimed binary containers show the empty state without reading. From open to first content every preview shows one centred icon-only spinner, and PDF pages appear as quiet placeholder blocks. The file tree comes back where the reader left it after any tab switch. Registry unit tests pin `binaryDocumentPath` matching and `register`'s rejection of a binary suffix outside `extensions`, toolbar tests pin the fallback/menu/empty-state branches, files-body tests pin scroll capture on unmount and restore across a remount, and the keyless Web document-preview scenario measures the width-fitted SVG against the pane and asserts the viewer control's absence per suffix.

+ 35 - 0
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md

@@ -0,0 +1,35 @@
+# Agent Note:侧边栏文档预览打磨
+
+Status: implemented
+
+[English](2026-09-11-sidebar-document-preview-polish.md) | 中文
+
+## 问题
+
+侧边栏文档预览积累了多处体验缺陷(issue #3974)。图片按固有 CSS 像素尺寸渲染,宽图撑出面板、只能横向滚动。查看器下拉总是追加纯文本兜底,位图和 PDF 文件因此提供一个结果是不可读字节的「纯文本」选项,而只有一个真实渲染器的文件仍显示一个没有可切换项的控件。完全没有渲染器的二进制容器(视频、压缩包、office 文档)落入纯文本读取器,呈现的是读取报错而非设计过的空态。各渲染器自带不同的 loading 文案和位置,打开文件时会闪过多个措辞、位置都不同的指示器。PDF 页面被双层内边距包裹,每一页都窄于面板宽度。切换侧栏 tab 会让文件树重挂载回到顶部,丢掉读者原来的位置。
+
+## 决定
+
+**图片宽度适配。** 图片外框跟随滚动容器的宽度并带 12px 内边距;图片本身使用 `max-width: 100%` 和 8px 圆角,宽图按纵横比缩小到面板宽度,小图保持固有尺寸并由 auto margin 居中,超高图在共享正文中纵向滚动。高度适配曾被考虑后放弃:它需要外框定高,且混合纵向场景会在没有用户诉求的情况下产生意外布局。
+
+**二进制查看器选项。** `DocumentPreviewDefinition` 新增可选的 `binaryExtensions`,即渲染器 `extensions` 中字节不可按文本阅读的后缀;`register` 拒绝不在 `extensions` 内的条目。这份知识由渲染器持有:图片正文声明 `png, jpg, jpeg, gif, webp, bmp, ico`,不含 `svg`,因为 SVG 源码是可读的 XML;PDF 正文声明 `pdf`。注册表模块的 `binaryDocumentPath` 判断是否有已注册定义把文件名后缀声明为二进制,预览 owner 对这类文件跳过纯文本兜底。头部仅在候选不少于两个时渲染查看器菜单;只剩一个候选时完全不渲染查看器控件。
+
+**不支持预览的空态。** owner 侧的 `document/unviewable.ts` 列出没有渲染器认领的二进制容器后缀(视频、音频、压缩包、office 文档、可执行文件、字体、磁盘镜像、设计格式)。owner 仅在没有实现匹配时查询该列表,因此任何渲染器注册始终优先;匹配的文件显示路径头部、文件类型图标和一行 `unsupportedFile` 说明,并且不会发起读取。不确定的后缀不进入列表、保留纯文本兜底;被文本认领但字节未通过读取器检查的文件通过 `error.notText` 报告同一句文案。
+
+**统一 loading。** `LoadingIndicator` 只渲染图标,标签作为 `aria-label` 携带、没有可见文字;所有渲染器的 loading 文案统一为共享的「正在读取…」。内容出现前的每个等待——owner 首次读取、PDF 解析、HTML 打包、图片解码——都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。未渲染的 PDF 页以主题骨架色 `--dsw-alias-bg-skeleton` 上的静态 3:4 占位块保持位置、不带 spinner;文档打开的等待保持为 owner 的居中读取 spinner,而不是特化进第一页的占位块,因为读取 spinner 在占位块之前出现,两者交接会明显跳位。占位块不带扫光动画,其逐页动画成本高于价值。
+
+**PDF 铺满与文案。** 移除 PDF 正文和页面的内边距,页面贴边占满面板宽度;图片渲染器保留自己的 12px 内边距。预览各字典的中文报错与状态文案去掉句尾句号。
+
+**文件树滚动恢复。** `ui-sidebar-files` 沿用文档预览自己的模式:树存储新增 `scrollTop` 与 `scrolled` action,正文在本地记录自己的滚动偏移、只在卸载时且 owner 的 signal 仍存活的情况下提交一次,重挂载时在 layout effect 中恢复。已加载的层本就在存储中比正文活得久,因此重挂载的树在偏移落回之前已按完整高度布局。
+
+## 曾考虑的替代方案
+
+**按定义级布尔 `binary` 标志。** 图片渲染器在一个注册中同时覆盖位图和 SVG,二进制与否是后缀的属性,不是渲染器的属性。
+
+**在预览 owner 中按硬编码后缀列表过滤已注册渲染器。** owner 会重复各渲染器已持有的知识,外部渲染器也无法扩展该集合;owner 侧列表只服务于没有渲染器认领的后缀。
+
+**单项静态查看器标签。** 把唯一候选渲染为静态名称,会在头部放一个不提供任何操作的控件;它只是噪音,因此头部什么都不渲染。
+
+## 后果
+
+图片不再产生横向滚动;面板宽度是唯一布局输入,因此未增加缩放控件。位图和 PDF tab 不显示查看器控件;SVG 保留含纯文本选项的菜单;未被认领的二进制容器显示空态且不读取。从打开到首个内容,每种预览都只显示一个居中的纯图标 spinner,PDF 页面以安静的占位块出现。任何 tab 切换之后,文件树都回到读者离开的位置。注册表单元测试固定 `binaryDocumentPath` 的匹配行为与 `register` 对 `extensions` 之外二进制后缀的拒绝,工具栏测试固定兜底/菜单/空态分支,files-body 测试固定卸载时的滚动捕获与跨重挂载的恢复,keyless Web document-preview 场景按面板测量适配宽度后的 SVG 并按后缀断言查看器控件的有无。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.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-14-docs-mermaid-viewer.md
+2026-09-14-docs-mermaid-viewer.md: 03aecb7b4df1c79d2eb254375f6b2c68145079ac
+2026-09-14-docs-mermaid-viewer.zh.md: b66f427a4820eef0d6fa25b163cfc8e3548ec0c6

+ 33 - 0
.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md

@@ -0,0 +1,33 @@
+# Agent Note: Documentation Mermaid viewer
+
+Status: implemented
+
+English | [中文](2026-09-14-docs-mermaid-viewer.zh.md)
+
+## Problem
+
+Complex Mermaid diagrams lose readable detail when scaled to the documentation column. Long sequence diagrams need both magnification and movement to inspect interactions while retaining an overview.
+
+## Decision
+
+The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. Its visible title also names it for assistive technology; a missing or blank page heading uses the localized viewer title. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting.
+
+The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram.
+
+Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners.
+
+## Alternatives considered
+
+**Widening the document column.** A wider column cannot provide readable detail for arbitrarily large diagrams, and long diagrams still exceed the viewport.
+
+**A custom overlay with document-wide listeners.** A native dialog already makes the background inert and handles modal dismissal. Theme-owned resources make navigation and teardown explicit; persistent document listeners would require a separate lifetime mechanism.
+
+**A bitmap preview or a same-document SVG clone.** A bitmap loses vector detail at high zoom. A same-document clone duplicates Mermaid's IDs and embedded styles; rewriting all SVG and CSS references would add a parser obligation that a shadow root avoids.
+
+## Consequences
+
+The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source.
+
+The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering, accessible titles, initial and resized fit, control wiring, keyboard cycling, replacement, and resource release.
+
+**CI coverage gap.** The DOM tests mock Panzoom and do not execute browser layout. Native modal behavior, SVG markers, pointer-anchored wheel zoom, canvas dragging, theme colors, and narrow-screen geometry require real-browser verification. The recorded browser demonstration supplies evidence for the current implementation, but it is not an automated regression check.

+ 33 - 0
.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 文档站 Mermaid 查看器
+
+Status: implemented
+
+[English](2026-09-14-docs-mermaid-viewer.md) | 中文
+
+## 问题
+
+复杂 Mermaid 图表缩放到文档正文列宽后,细节难以辨认。阅读长时序图需要在保留全图概览的同时放大和平移,以检查交互细节。
+
+## 决策
+
+[VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。可见标题也用于辅助技术识别对话框;页面标题缺失或为空白时,使用本地化的查看器标题。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。
+
+查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。
+
+查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。
+
+## 考虑过的替代方案
+
+**加宽文档正文列。** 更宽的正文列无法让任意大小图表的细节都清晰可读,长图仍然会超出视口。
+
+**使用自定义遮罩和文档级监听器。** 原生对话框已经能使背景不可交互,并处理模态退出。由主题持有资源让导航和资源清理的职责明确;持久的文档级监听器则需要单独的生命周期机制。
+
+**位图预览或同文档内的 SVG 副本。** 位图在高倍率缩放下会丢失矢量细节。同文档内的副本会重复 Mermaid 的 ID 和内嵌样式;重写全部 SVG 和 CSS 引用会带来额外的解析职责,而 shadow root 可以避免这项职责。
+
+## 影响
+
+文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。
+
+[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染、无障碍标题、初始及窗口变化后的适配、控件连接、键盘焦点循环、图表替换和资源释放。
+
+**CI 覆盖缺口。** DOM 测试模拟了 Panzoom,不执行浏览器布局。原生模态行为、SVG 标记、以指针为锚点的滚轮缩放、画布拖动、主题颜色和窄屏几何布局需要真实浏览器验证。浏览器演示记录提供了当前实现的证据,但不属于自动回归检查。

+ 6 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md
+2026-09-14-composer-model-and-draft-editor.md: 3f66ab2c73da0a8f66bf43a821cbe5f85363867a
+2026-09-14-composer-model-and-draft-editor.zh.md: aa040b0fa68961d4d5884d73791c4986b8bfab50

+ 116 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md

@@ -0,0 +1,116 @@
+# Agent Note: Two-stage Composer and DraftEditor isolation
+
+Status: proposed
+
+English | [中文](2026-09-14-composer-model-and-draft-editor.zh.md)
+
+## Problem
+
+One Client needs to edit the same Session's draft and pending attachments in multiple views. A Lexical editor binds only one DOM root; multiple presentation locations need multiple editor instances, but must not own unrelated drafts or upload tasks, or make the Session Controller understand carets, composition, or DOM state.
+
+The current [SessionInputShell](../../../../packages/client/ui-conversation/src/client/input/facade.ts) combines Lexical operations, draft projection, the submission state machine, and failure recovery. [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx) combines editor presentation, DOM bindings, attachment intake, and submission controls. Implementing multiple instances directly in these files would mix code extraction with behavior changes.
+
+[ConversationController](../../../../packages/client/ui-conversation/src/client/service.ts) already owns attachment entities and upload tasks centrally; the shell retains only ordered attachment IDs. Selecting a skill inserts ordinary `/name` text whose highlighting derives from a lexicon; atomic file and Session references use chips carrying source identity. A shared draft must not lose these references by synchronizing text alone, and does not require copying attachment entities.
+
+This proposal details editor isolation for [#3951](https://github.com/deepseek-ai/deepseek-harness/pull/3951), following [Client Session and UI ownership](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.md). The Session activity view, residency states, and eviction policy are designed independently; [#4138](https://github.com/deepseek-ai/deepseek-harness/pull/4138) is only a Host lifecycle reference. This proposal implements none of those features and does not repeat the Conversation component decomposition in #3984.
+
+## Proposal
+
+Use two independent PRs. Stage one concentrates existing editor implementation into explicit locations for behavior changes; stage two changes behavior only. `DraftEditor` names the draft-editing area, while Composer names the complete writing area including attachments and submission controls. Keep `input/`, `skeleton/`, `InputBar`, `InputHub`, and `SessionInputShell`; directory moves and renames are not refactoring deliverables.
+
+### Stage one: five mechanical responsibility extractions
+
+The ui-conversation paths below are relative to `packages/client/ui-conversation/src/client/`. Every new file must contain logic already executed today, not placeholder interfaces or future features.
+
+| Original location | Extraction destination | Location for later behavior changes |
+|---|---|---|
+| Lexical creation, registration, projection, node operations, and cleanup in `input/facade.ts` | `input/editor/runtime.ts` | One editor's implementation and its creation, binding, and disposal |
+| Text-area JSX in `skeleton/InputBar.tsx` | `input/editor/DraftEditor.tsx` | One editor's presentation, excluding the attachment rail and submission orchestration |
+| Focus, selection reveal, wheel, keymap, and picker binding functions in `InputBar.tsx` | `input/editor/view-binding.ts` | DOM interaction and editor bindings for one mounted view |
+| Range, reference, and keyboard interface types in `contract/input.ts` | `contract/draft-editor.ts` | Editor-facing data and operation types; submission and shared state stay in the original file |
+| Document drop effect implementation in `ui-attachment/src/client/ComposerAttachments.tsx` | `ui-attachment/src/client/drop-events.ts` | Document drag-and-drop registration, routing, and cleanup |
+
+The existing shell creates and delegates to the internal object in `runtime.ts`. That object retains the original editor, NodeKey map, projection, and Lexical registrations; it neither copies those states nor independently decides whether editing or submission is permitted. The shell retains SubmitMachine, draft revision, attachment IDs, notices, attempts, serialization, and success/failure recovery decisions.
+
+Methods combining guards and node operations retain their guards at the original location. For example, beginCommand keeps its span/phase checks, node replacement, and machine dispatch in the same order; failure recovery preserves batch ordering, revision guards, restoration flags, and history cleanup timing. Editor updates still call the shell synchronously at the original publication point, without another Promise, effect, or notification turn.
+
+`DraftEditor.tsx` extracts presentation without adding a DOM wrapper. All existing React hooks, refs, dependency arrays, and relative effect order remain in InputBar; effects delegate to ordinary functions at their original call sites. CSS files, class keys, React keys, placeholder order, and decorator order remain unchanged. The new component does not take over editor creation or hold another draft.
+
+`contract/draft-editor.ts` receives `TokenSpan`, `ReferenceInsert`, `ArbitrateKey`, `ArbitrateOutcome`, `ComposerKeyboard`, `EditSelection`, and `Occurrence`. Names and members remain unchanged, consumers import from the actual declaration owner, and existing public exports retain their names and visibility. `ComposerKeyboard` temporarily still depends on shared `InputState`; this is not an independent controlled-editor protocol.
+
+#### Stage-one invariants
+
+- InputHub still creates one shell and one editor per Session, with unchanged creation, reuse, and disposal timing and counts.
+- Lexical remains the draft authority; Undo/Redo, NodeKey identity, span checks, and revision rules remain unchanged, without a second document or store.
+- `useInput`, `inputActions`, Slots, events, inject declarations, and public APIs retain their names, payloads, and behavior; Host protocols and persistence formats do not change.
+- Attachment selection, upload timing, image previews, submission batches, success clearing, failure restoration, and notice rules remain unchanged.
+- Each original component still registers document drop listeners in the same effect; the single picker, duplicate drop, and single editor/root limitations remain.
+- Tests change only type imports that actually need updating; test filenames, assertions, recorded Sessions, and expected outputs remain unchanged, without snapshot refreshes.
+- No existing file moves, existing private-name changes, CSS changes, new packages, dependencies, renderer scopes, or general state framework.
+
+#### Isolation actually achieved in stage one
+
+| Subject | Result |
+|---|---|
+| Editor implementation | Node and projection operations belong to runtime, presentation to DraftEditor, and DOM bindings to view-binding |
+| Submission and attachment orchestration | Still owned by the original shell and ConversationController, not DraftEditor |
+| State across different Sessions | Remains isolated under existing rules |
+| Shared state within one Session | Still reuses the original shell, without duplicate attachments or uploads |
+| Independent editors within one Session | Not implemented; views still share one Lexical editor |
+| Independent selection, IME, Undo, and menu origins | Not implemented; separating code does not change runtime ownership |
+| Multi-view picker, focus, and document drop routing | Not implemented; binding code has a separate location for modification |
+
+### Stage two: behavior changes only
+
+Stage two implements a shared draft and multiple editors directly in the locations above. It must not move existing files or directories, perform pure renames or helper/class/component extractions, reorder existing tests, or clean up formatting or comments. New types, implementations, and tests required by new behavior may be added, but copying old code into a new file and deleting its original does not evade this restriction.
+
+If behavior implementation still needs structural preparation, complete stage one first: amend its PR before merge, or add a separate mechanical prerequisite PR after merge. The behavior PR uses that mechanical result as its base and cannot include the preparation.
+
+#### Final state ownership
+
+The shared Composer model evolves the responsibilities of the existing SessionInputShell without requiring another rename. The Session Controller continues to own only Session business state and does not import DraftEditor, Lexical, or the shared draft document.
+
+| State | Final owner | Multi-view requirement |
+|---|---|---|
+| Draft text, semantic references, and content revision | Session-associated shared Composer model | Publish edits from either view to every view through one reactive source |
+| Ordered attachment IDs, claims, submission attempts, and failure recovery | Shared Composer model | Settle each submission once; operations in either view affect the same pending input |
+| File, Blob URL, upload tasks, progress, and receipts | Existing attachment owner | Do not copy per view; unmounting one view does not cancel resources used by another |
+| Lexical, DOM, NodeKey mappings, selection, and IME preedit | Each DraftEditor instance | Two independent editors/roots; unmounting one does not detach the other |
+| Menu anchor, file dialog, and focus | Initiating view | Route by operation origin, not a single Session picker |
+| Session history, running, and queue | Session Controller | Keep reading existing sources instead of copying them into the draft model |
+
+The renderer still binds React hooks from bare observables, and business components read and write through existing standard props. The shared model accepts neither DOM, Lexical NodeKeys, nor composition intermediate state; DraftEditor receives neither Session/Context nor upload services, only draft data, presentation data, and editing/intent callbacks.
+
+#### Shared content and synchronization requirements
+
+Draft content must represent ordinary text, newlines, and atomic references with complete `ReferenceInsert` information independently of Lexical. Shared reference identity must not depend on one editor's NodeKey; each instance privately maps it to its own nodes. Skills remain ordinary `/name` text, with both views deriving highlights from the same text and lexicon, without an extra selected-skill list or changes to Host recognition.
+
+Draft text is small, so synchronization may use complete semantic documents without requiring a collaborative-editing algorithm. The shared model accepts edits, assigns revisions, and publishes; editors distinguish local changes from external rendering to avoid feedback loops. Callbacks from stale revisions, prior model lifetimes, or unmounted views must not overwrite current content. Submission freezing, success clearing, failure restoration, and attachment changes must reach all views through the same shared source.
+
+IME preedit belongs to the local instance, and updates from another view must not directly disrupt text under composition. Stage two must define and verify how another view's edits, submission clearing, and model release interact with composition. Undo/Redo must also operate on one logical draft, rather than letting two Lexical histories restore stale whole documents over each other; synchronization and history implementation are outside the mechanical stage.
+
+Programmatic insertion, menu selection, file selection, and focus restoration need the initiating view's temporary identity. Closing that view must not redirect late UI actions into another view of the same Session. Document drop must select one explicit target and process the drop exactly once; origin routing and deduplication are stage-two behavior.
+
+Existing text-draft restoration after refresh must remain, without implicitly promising persistence for structured references, File objects, or cross-browser collaboration. The Session activity view and LRU/timeout policy remain independent of this editing protocol.
+
+## Alternatives considered
+
+**Only rename input or relocate it to composer.** This does not separate Lexical operations, view bindings, and submission decisions; behavior implementation would still need to extract old code from large files, so it is not a stage-one deliverable.
+
+**Bind one Lexical editor to two DOM roots.** This conflicts with Lexical's single-root model; copying React presentation does not create two independently interactive editors.
+
+**Give each Composer an independent draft and attachments.** This fails the same-Session shared-editing requirement and introduces conflicting attachment and submission ownership.
+
+**Implement a shared DraftDocument, Undo, or drop deduplication in the mechanical stage.** This changes authority, lifecycle, or event-processing counts and cannot be reviewed as behavior-preserving preparation.
+
+## Acceptance criteria
+
+Stage one completes the five extractions and required imports, JSDoc, and README updates; review compares original method bodies, branches, callback order, hooks, DOM, and cleanup. Existing editing, reference, claim, attachment, submission, failure-restoration, and unmount tests continue to pass; focused browser regressions run against built artifacts with unchanged expected output. Type and documentation checks cover relocated declarations and bilingual pairs. New dual-instance functionality is not a stage-one acceptance condition.
+
+Stage two uses two genuinely mounted Composers for one Session to verify bidirectional text and chip synchronization, skill highlights, shared attachments and progress, submission clearing/failure restoration, IME/Undo, origin routing, and continued operation after either view unmounts. Its diff contains behavior implementation and corresponding tests only, without mechanical cleanup.
+
+## Risks
+
+Even stateless JSX extraction can alter ref or effect timing; therefore hooks and refs retain their host, and DOM gains no wrapper. Lexical extraction can alter nested updates, projection caching, or history cleanup order; therefore retain original operation bodies and compare execution order instead of rewriting algorithms.
+
+Stage one still cannot mount two editors for one Session and retains the existing picker/drop limitations. Confusing directory isolation with state isolation could cause roots to detach each other, duplicate attachment intake, or misroute focus; stage-two dual-instance behavior tests must close these gaps.

+ 116 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md

@@ -0,0 +1,116 @@
+# Agent Note: Composer 与 DraftEditor 隔离的两阶段重构
+
+Status: proposed
+
+[English](2026-09-14-composer-model-and-draft-editor.md) | 中文
+
+## 问题
+
+同一个 Client 需要在不同视图中编辑同一个 Session 的草稿和待发送附件。Lexical 的一个 editor 只能绑定一个 DOM root;多个呈现位置需要多个编辑实例,但不能各自拥有互不相干的草稿和上传任务,也不能让 Session Controller 理解光标、输入法或 DOM。
+
+当前 [SessionInputShell](../../../../packages/client/ui-conversation/src/client/input/facade.ts) 同时包含 Lexical 操作、草稿投影、提交状态机和失败恢复。[InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx) 同时包含编辑区呈现、DOM 绑定、附件入口和发送控件。直接在这两个文件中实现多实例会让代码提取与行为差异混在一起。
+
+附件实体和上传任务已经由 [ConversationController](../../../../packages/client/ui-conversation/src/client/service.ts) 集中管理,shell 只保留有序附件 IDs。skill(技能)选择插入普通 `/name` 文本,高亮由词表派生;文件和 Session 的原子引用则使用带来源身份的 chip。共享草稿不能只同步文字而丢失这些引用,也不需要复制附件实体。
+
+本提案细化 [#3951](https://github.com/deepseek-ai/deepseek-harness/pull/3951) 的编辑器隔离,遵循 [Client Session 与 UI 所有权](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md)。Session 活跃视图、驻留状态与回收策略独立设计;[#4138](https://github.com/deepseek-ai/deepseek-harness/pull/4138) 仅作为 Host 生命周期参考。本提案不实现这些功能,也不重复 #3984 的 Conversation 组件拆分。
+
+## 提案
+
+采用两个独立 PR(Pull Request)。第一阶段集中既有编辑实现,为行为改动准备明确位置;第二阶段只修改行为。`DraftEditor` 专指草稿编辑区,Composer 指包含附件和发送控件的完整编写区域。保留 `input/`、`skeleton/`、`InputBar`、`InputHub` 和 `SessionInputShell`,不以改目录或改名作为重构成果。
+
+### 第一阶段:五处机械职责提取
+
+以下 ui-conversation 路径相对 `packages/client/ui-conversation/src/client/`。每个新文件必须承载当前已经执行的逻辑,不建立占位接口或未来功能。
+
+| 原位置 | 提取位置 | 后续行为修改的落点 |
+|---|---|---|
+| `input/facade.ts` 的 Lexical 创建、注册、投影、节点操作和清理 | `input/editor/runtime.ts` | 单个 editor 的实现及其创建、绑定和释放 |
+| `skeleton/InputBar.tsx` 的文字区域 JSX | `input/editor/DraftEditor.tsx` | 单份编辑区的呈现,不包含附件栏和提交编排 |
+| `InputBar.tsx` 的 focus、selection reveal、wheel、keymap、picker 绑定函数 | `input/editor/view-binding.ts` | 单个挂载视图的 DOM 交互和编辑器绑定 |
+| `contract/input.ts` 的范围、引用、键盘接口类型 | `contract/draft-editor.ts` | 编辑器对外数据与操作类型;提交和共享状态仍留原文件 |
+| `ui-attachment/src/client/ComposerAttachments.tsx` 的 document drop effect 实现 | `ui-attachment/src/client/drop-events.ts` | document 拖放监听的注册、路由和清理 |
+
+`runtime.ts` 内部对象由现有 shell 创建并委托调用。它保管原 editor、NodeKey 映射、投影和 Lexical 注册;不复制这些状态,也不独立决定能否编辑或提交。shell 继续持有 SubmitMachine、draft revision、附件 IDs、通知、attempt、序列化以及成功/失败恢复决策。
+
+同时涉及判断和节点操作的方法在原位置保留判断。例如 beginCommand 的 span/phase 检查、节点替换、machine dispatch 的顺序不变;失败恢复的批次排序、revision 保护、恢复标志和 history 清理时点不变。editor 更新仍在原同步位置回调 shell 发布状态,不增加 Promise、effect 或通知轮次。
+
+`DraftEditor.tsx` 是无额外 DOM 包装的呈现提取。所有既有 React 钩子、refs、依赖数组和 effect 相对顺序仍留在 InputBar;effect 只在原调用位置委托普通函数。CSS 文件、class keys、React keys、placeholder 与 decorator 顺序均不变。新组件不接管 editor 创建或持有另一份草稿。
+
+`contract/draft-editor.ts` 移入 `TokenSpan`、`ReferenceInsert`、`ArbitrateKey`、`ArbitrateOutcome`、`ComposerKeyboard`、`EditSelection` 和 `Occurrence`。名称和成员不变,所有消费方从实际声明处导入;既有公开出口保持原名称和可见集合。`ComposerKeyboard` 仍暂时依赖共享 `InputState`,这不是独立受控编辑协议。
+
+#### 第一阶段不变项
+
+- InputHub 仍按 Session 创建一个 shell 和一个 editor,创建、复用、dispose(资源释放)的时点及次数不变。
+- 草稿真值仍在 Lexical,Undo/Redo、NodeKey 身份、span 检查和 revision 规则不变;不增加第二份文档或存储。
+- `useInput`、`inputActions`、Slot、事件、inject 及公开 API 的名称、载荷和行为不变;不改 Host 协议和持久化格式。
+- 附件选择、上传时机、图片预览、提交批次、成功清空、失败恢复及通知规则不变。
+- document drop 仍在每个原组件的同一 effect 中注册;单 picker、重复 drop、单 editor/root 的限制原样保留。
+- 测试只修改实际需要的类型导入;不改测试文件名、断言、录制 Session 或预期输出,不刷新快照。
+- 不搬已有文件,不改已有私有名字,不改 CSS,不增包、依赖、renderer scope 或通用状态框架。
+
+#### 第一阶段实际隔离程度
+
+| 内容 | 完成后的状态 |
+|---|---|
+| 编辑器实现 | 节点和投影操作归 runtime,呈现归 DraftEditor,DOM 绑定归 view-binding |
+| 提交与附件编排 | 仍由原 shell 和 ConversationController 管理,不落入 DraftEditor |
+| 不同 Session 的状态 | 继续按原规则隔离 |
+| 同 Session 的共享状态 | 继续复用原 shell;没有双份附件或上传任务 |
+| 同 Session 的独立编辑实例 | 未实现,仍共享一个 Lexical editor |
+| 独立 selection、IME、Undo 和菜单来源 | 未实现;代码位置分开不代表运行时归属已改变 |
+| picker、focus、document drop 的多视图路由 | 未实现;绑定代码已有单独修改位置 |
+
+### 第二阶段:只调整行为
+
+第二阶段直接在上述位置实现共享草稿和多编辑实例。禁止移动已有文件或目录、纯改名、纯提取 helper/class/component、重排已有测试,以及格式或注释清理。新增行为需要的新类型、实现和测试可以增加,但不得复制旧代码到新文件后删除原文来规避约束。
+
+如果行为实现仍需要结构准备,必须先补第一阶段:未合入时修改第一阶段 PR;已合入时单独增加机械前置 PR。行为 PR 以该机械结果为 base,不能夹带机械准备。
+
+#### 最终状态归属
+
+共享 Composer 模型是现有 SessionInputShell 的职责演进,不要求再次改名。Session Controller 继续只管理会话业务,不 import DraftEditor、Lexical 或共享草稿文档。
+
+| 状态 | 最终 owner | 多视图要求 |
+|---|---|---|
+| 草稿正文、语义引用、内容 revision | Session 关联的共享 Composer 模型 | 任一处编辑后,通过同一个响应式来源发布给所有视图 |
+| 有序附件 IDs、认领、提交 attempt 和失败恢复 | 共享 Composer 模型 | 每个提交只结算一次,任一视图的操作作用于同一批输入 |
+| File、Blob URL、上传任务、进度和凭证 | 既有附件管理 owner | 不随视图复制;卸载一个视图不取消其他视图所用资源 |
+| Lexical、DOM、NodeKey 映射、selection、IME preedit | 各 DraftEditor 实例 | 两个独立 editor/root;卸载一处不解绑另一处 |
+| 菜单锚点、文件对话框和焦点 | 发起操作的视图 | 按操作来源路由,不以 Session 唯一 picker 代替来源 |
+| Session 历史、running、queue | Session Controller | 继续读取现有来源,不复制进草稿模型 |
+
+React 钩子仍由 renderer 从裸 observable 绑定,业务组件通过现有标准 props 读写。共享模型不接收 DOM、Lexical NodeKey 或输入法中间态;DraftEditor 不接收 Session/Context 或上传服务,只接收草稿数据、显示数据与编辑/意图回调。
+
+#### 共享内容和同步要求
+
+草稿内容必须能独立于 Lexical 表示普通文本、换行和带完整 `ReferenceInsert` 信息的原子引用。引用的共享身份不能依赖某个 editor 的 NodeKey;各实例私有映射到自己的节点。skill 保持普通 `/name` 文本,两处从同一文本和词表派生高亮,不引入额外的已选 skill 列表或改变 Host 识别规则。
+
+草稿文本量小,可以使用完整语义文档同步,不要求协同编辑算法。共享模型负责接受编辑、分配 revision 和发布,编辑器区分本地产生的变化与外部呈现,避免回声循环。旧 revision、旧模型生命周期和已卸载视图的回调不能覆盖新内容。提交冻结、成功清空、失败恢复和附件变化必须通过同一共享来源到达所有视图。
+
+IME preedit 属于本地实例,远端视图更新不能直接破坏正在组合的文本。来自另一视图的修改、发送清空和模型释放如何与组合态相遇,必须在第二阶段定义并验证。Undo/Redo 也必须作用于同一份逻辑草稿,不能让两份 Lexical history 互相恢复陈旧整篇文档;具体同步与历史实现不属于机械阶段。
+
+程序化插入、菜单选择、文件选择器和焦点恢复要携带发起视图的临时身份。视图关闭后不能把迟到的 UI 操作转发给另一个同 Session 视图。document drop 必须明确一次拖放选哪个目标并保证仅处理一次;来源路由和去重均是第二阶段行为。
+
+刷新后的既有文字草稿恢复应保留,但不默认新增结构化引用、File 或跨浏览器协作的持久化承诺。Session 活跃视图及 LRU/时限策略不与这份编辑协议绑定。
+
+## 考虑过的替代方案
+
+**仅把 input 改名或平移到 composer。** 不能分离 Lexical 操作、视图绑定和提交决策,后续行为实现仍需从大文件中提取旧代码,因此不作为第一阶段成果。
+
+**让一个 Lexical editor 同时挂两个 DOM root。** 与 Lexical 的单 root 模型冲突,不能用 React 复制呈现来获得两个可独立交互的编辑器。
+
+**每个 Composer 独立草稿和附件。** 不满足同一 Session 共享编辑的要求,还会引入附件和提交所有权分歧。
+
+**机械阶段直接实现共享 DraftDocument、Undo 或 drop 去重。** 改变真值、生命周期或事件处理次数,无法作为行为不变的前置改动审查。
+
+## 验收标准
+
+第一阶段必须完成五处提取及必要导入、JSDoc 和 README 更新;逐项核对原方法体、分支、回调次序、Hooks、DOM 和清理。现有编辑、引用、认领、附件、提交、失败恢复和卸载测试继续通过;用构建产物运行针对性浏览器回归,预期输出不变。类型和文档检查覆盖移动后的声明与双语配对。不以新双实例功能作为第一阶段验收条件。
+
+第二阶段必须用同一 Session 的两个真实挂载 Composer 验证双向文字和 chip 同步、skill 高亮、共享附件和进度、发送清空/失败恢复、IME/Undo、来源路由,以及任一视图卸载后另一处继续工作。其差异必须仅包含行为实现及相应测试,不包含机械整理。
+
+## 风险
+
+无状态 JSX 提取仍可能改变 ref 或 effect 时序;因此 Hook 和 ref 的宿主保持不变,DOM 不增加包装。Lexical 提取可能改变嵌套 update、projection 缓存或 history 清理顺序;因此保留原操作体并对照执行次序,而非重写算法。
+
+第一阶段仍不能同时挂载同 Session 的两个编辑器,且保留原 picker/drop 限制。后续若误把目录隔离当成状态隔离,会造成 root 相互解绑、重复附件接收或错误焦点路由;这些限制必须由第二阶段的双实例行为测试关闭。

+ 2 - 0
THIRD_PARTY_NOTICES.md

@@ -159,6 +159,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
 | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
 | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
+| [`@panzoom/panzoom`](https://github.com/timmywil/panzoom) | MIT |
 | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |
 | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
 | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
@@ -219,6 +220,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`vitepress`](https://github.com/vuejs/vitepress) | MIT |
 | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT |
 | [`vitest`](https://github.com/vitest-dev/vitest) | MIT |
+| [`vue`](https://github.com/vuejs/core) | MIT |
 
 `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.
 

+ 1 - 1
apps/cli/package.json

@@ -99,7 +99,7 @@
     "@deepseek-ai/schemastery": "workspace:^",
     "commander": "^15.0.0",
     "js-yaml": "^4.2.0",
-    "node-addon-require-builtin": "^0.1.4",
+    "node-addon-require-builtin": "^0.1.6",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
     "@deepseek-ai/dsh-mcp-resources": "workspace:^"
   },

+ 2 - 2
apps/cli/reference/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/cli/reference/README.md
-README.md: 5a92052eb24c8af432c2ede371a2ff2bc84f57c0
-README.zh.md: a9268ad4920a7d5a72a4b0ead9b2672d22e10805
+README.md: b2931e05c1c10c6de13427b2cdaf38a0e78db904
+README.zh.md: 2a9014a5d338c3d81d9976d8cb47474a95c45cb5

+ 3 - 3
apps/cli/reference/README.md

@@ -8,7 +8,7 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
 
 `dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
 
-Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. Every launch also links packages carried only by selected external bundles through a dsh-owned directory into the current profile's `node_modules`; existing pnpm entries win, and each profile owns its links independently.
+Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
 
 The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
 
@@ -98,9 +98,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission
 
 ## Shared deployment behavior
 
-The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, opt-in DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
+The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
 
-Feedback is recorded in the Session log without starting model work. Enable the [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) to send complete unaccepted log suffixes with subsequent DeepSeek requests, including requests sent through configured gateways. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
+Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
 
 Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
 

+ 3 - 3
apps/cli/reference/README.zh.md

@@ -10,7 +10,7 @@
 
 `dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
 
-组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。每次启动还会把仅由所选外部组合包携带的包经 dsh 自有目录链接到当前 profile 的 `node_modules`;已有 pnpm 条目优先,且每个 profile 独立拥有自己的链接
+组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级
 
 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
 
@@ -100,9 +100,9 @@ dsh web --help
 
 ## 共享部署行为
 
-基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,需主动开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
+基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
 
-反馈记录在会话日志中,不会启动模型工作。开启 [DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)后,后续 DeepSeek 请求会发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
+反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;将其 `enabled` 配置设为 `false` 可关闭上传。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
 
 通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
 

+ 22 - 5
apps/cli/src/profile-boot.ts

@@ -19,6 +19,10 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
+  type ProfileResolutionMode,
+  type ProfileResolutionGeneration,
+  PluginPackages,
+  createProfileResolutionGeneration,
   composeEntries,
   healProfilesModuleFallback,
   healIsolatedProfileModuleFallback,
@@ -194,6 +198,7 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
 
 /** One profile's patch layers, in application order. */
 interface ComposedProfile {
+  resolution: ProfileResolutionGeneration
   profile: Profile
   /** Bundle layers concatenated — the part below the user layers on a live reload. */
   bundlePatches: PatchOptions[]
@@ -229,13 +234,17 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
 async function composeProfile(
   name: string,
   patchFiles: readonly string[],
+  resolutionMode: ProfileResolutionMode,
   fromDefaultProfile?: string,
   resolvedProfile?: ResolvedProfileRuntime,
 ): Promise<ComposedProfile> {
   const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
   if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
-  if (resolvedProfile === undefined) await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
-  else healIsolatedProfileModuleFallback(resolvedProfile)
+  const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
+  if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
+  const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
+    ? await createProfileResolutionGeneration(resolutionOptions)
+    : await healProfilesModuleFallback(resolutionOptions)
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
   const bundlePatches = profile.layers.flatMap(layer => layer.patches)
@@ -246,7 +255,7 @@ async function composeProfile(
   const composedOverlays = [...overlays]
   const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
   if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, bundlePatches, homePatches, overlays: composedOverlays }
+  return { profile, resolution, bundlePatches, homePatches, overlays: composedOverlays }
 }
 
 /** An application-owned profile and its independent installation fallback. */
@@ -259,6 +268,8 @@ export interface ResolvedProfileRuntime {
 
 /** Options for {@link runProfile}. */
 export interface RunProfileOptions {
+  /** Package lookup strategy; packaged executables always use runtime resolution. */
+  resolutionMode?: ProfileResolutionMode
   /** This run's frozen environment snapshot, provided before any entry mounts. */
   environment: LaunchEnvironmentSnapshot
   /** The profile name to boot. */
@@ -306,6 +317,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
   )
 
+  const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
+  const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
   const app: { current?: Context } = {}
   let disposal: Promise<void> | undefined
   const dispose = (): Promise<void> => disposal ??= (async () => {
@@ -318,7 +331,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   })()
   try {
     const composed = await composeProfile(
-      options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,
+      options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
     )
     const appReady = createAppReady()
     const shutdown = createProcessShutdown(dispose)
@@ -359,11 +372,15 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     ])
     // Cloned for the same insert-aliasing reason as composeLive: the boot
     // application must not mutate the objects later reloads recompose from.
-    const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
+    const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
       app.current = hostCtx
       // Before any config-tree entry mounts, so plugins resolve all launch-time
       // environment values from the same immutable launch snapshot.
       hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
+      await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
+        generation: composed.resolution,
+        behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
+      })
       // The command line and bounded exit request are launcher facts available
       // to every app plugin that injects the argument snapshot.
       provideCmdline(hostCtx, {

+ 1 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -868,6 +868,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     try {
       await waitForFile(fixture.ready)
       expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
+      expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true)
       requestProfileShutdown(child, fixture)
       expect((await child).exitCode).toBe(0)
     } finally {

+ 4 - 0
apps/cli/tests/profiles/headless/goal-snapshot.patch.yml

@@ -24,3 +24,7 @@
 - insert:
     - id: llm-replay
       name: '@deepseek-ai/dsh-llm-replay'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 4 - 0
apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml

@@ -23,3 +23,7 @@
     # Publish the persisted agent before the driver selects the single root.
     - id: resumed-agent
       name: './tests/fixtures/semantic-checkpoint-agent.ts'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 4 - 0
apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml

@@ -26,3 +26,7 @@
     # Publish the persisted agent before the driver selects the single root.
     - id: resumed-agent
       name: './tests/fixtures/subagent-diagnostic-agent.ts'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 4 - 0
apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml

@@ -32,3 +32,7 @@
     # Publish the persisted agent before the driver selects the single root.
     - id: resumed-agent
       name: './tests/fixtures/subagent-inheritance-agent.ts'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 4 - 0
apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml

@@ -28,3 +28,7 @@
     # Hold the parent's second step until the manager notice enters its inbox.
     - id: settlement-fence
       name: './tests/fixtures/subagent-settlement-fence.ts'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 4 - 0
apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml

@@ -28,3 +28,7 @@
     # Publish the persisted agent before the driver selects the single root.
     - id: resumed-agent
       name: './tests/fixtures/workspace-context-resume-agent.ts'
+
+- id: session-log-deepseek
+  config:
+    enabled: false

+ 14 - 6
apps/cli/tests/resolved-profile-boot.spec.ts

@@ -1,5 +1,5 @@
 /** Application-owned profiles share the named profile launch lifecycle. */
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -30,6 +30,9 @@ describe('runProfile with an application-owned profile', () => {
   it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
     homes.push(home)
+    mkdirSync(join(home, 'runtime'))
+    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
+    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
     vi.stubEnv('DSH_HOME', home)
     vi.spyOn(process, 'on').mockReturnValue(process)
     const ctx = new Context()
@@ -76,9 +79,12 @@ describe('runProfile with an application-owned profile', () => {
     }
   })
 
-  it.each(['live', 'startup'] as const)('uses shared layers, %s reload, environment, and shutdown', async (patchReload) => {
+  it.each([['live', 'link'], ['startup', 'link'], ['startup', 'runtime']] as const)('uses shared layers, %s reload, %s resolution, and shutdown', async (patchReload, resolutionMode) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
     homes.push(home)
+    mkdirSync(join(home, 'runtime'))
+    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
+    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
     vi.stubEnv('DSH_HOME', home)
     vi.stubEnv('DSH_TELEMETRY_DISABLED', '1')
     vi.spyOn(process, 'on').mockReturnValue(process)
@@ -116,13 +122,15 @@ describe('runProfile with an application-owned profile', () => {
     const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
     try {
       const { shutdown } = await runProfile({
-        environment, profile: 'desktop', resolvedProfile: runtime,
+        environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode,
         patchFiles: [overlay], args: ['--port', '0', '--no-open'],
       })
       expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
-      expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({
-        profile, installAnchor: runtime.installAnchor,
-      })
+      if (resolutionMode === 'link') {
+        expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
+      } else {
+        expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
+      }
       expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale')
       expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open'])
       const ready = vi.fn()

+ 20 - 8
apps/cli/tests/web-agent-presets.e2e.ts

@@ -4,7 +4,14 @@ import { tmpdir } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import { dirname, join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
-import { boot, healProfilesModuleFallback, loadOverlayPatches, loadProfile } from '@deepseek-ai/dsh-app-boot'
+import {
+  boot,
+  createProfileResolutionGeneration,
+  loadOverlayPatches,
+  loadProfile,
+  PluginPackages,
+  type Profile,
+} from '@deepseek-ai/dsh-app-boot'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
 import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -112,12 +119,7 @@ async function bootWeb(
     { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
     ...extra,
   ]
-  // The surface is patch layers over an empty preset root, so the root sits
-  // outside this workspace and bare plugin names cannot resolve by Node's
-  // upward walk. The flat fallback the preset boot maintains is what makes
-  // them resolvable — the same mechanism, not a test-only shim.
   const home = dirname(settingsFile)
-  await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, home })
   const profileDir = join(home, 'profiles', 'spec')
   await mkdir(profileDir, { recursive: true })
   // Product Bundles are installed into the Profile, not the dsh app. Model
@@ -130,6 +132,14 @@ async function bootWeb(
     await mkdir(dirname(link), { recursive: true })
     await symlink(packageDir, link, 'junction')
   }
+  let profile: Profile = {
+    name: 'spec',
+    dir: profileDir,
+    layers: [],
+    patchPath: join(profileDir, 'cordis.patch.yml'),
+    patches: [],
+    patchReload: 'startup',
+  }
   let bundlePatches: PatchOptions[] = [
     ...loadOverlayPatches('dsh-test', BASE_PATCH),
     ...loadOverlayPatches('dsh-test', WEB_PATCH),
@@ -140,12 +150,14 @@ async function bootWeb(
       dependencies: Object.fromEntries(profileBundles.map(name => [name, 'workspace:*'])),
       dsh: { profile: { bundles: profileBundles } },
     }, null, 2) + '\n')
-    const profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false })
+    profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false })
     bundlePatches = profile.layers.flatMap(layer => layer.patches)
   }
+  const resolution = await createProfileResolutionGeneration({ installAnchor: INSTALL_ANCHOR, home, profile })
   const rootConfig = join(profileDir, 'cordis.yml')
   await writeFile(rootConfig, '[]\n')
-  return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => {
+  return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], async (bootCtx) => {
+    await bootCtx.plugin(PluginPackages, { generation: resolution })
     bootCtx.provide('connection', {
       fetch: { register: () => () => {} },
       rpc: { intercept: () => () => {} },

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

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

+ 1 - 0
apps/desktop-host/src/index.ts

@@ -16,6 +16,7 @@ async function main(): Promise<void> {
   const application = runProfile({
     environment: loadLayeredEnv('dsh'),
     profile: 'desktop',
+    resolutionMode: process.argv[5] === 'runtime' ? 'runtime' : 'link',
     resolvedProfile: { profile, installAnchor },
     patchFiles: [],
     args: ['--no-open', '--port', '19387'],

+ 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: a03acd3f4bfc717c6c4daa212d990e635bf15180
-README.zh.md: e0262df89cf36ee60ad801f2b6739d4dc5c97111
+README.md: 7a33d86aaded04a6e79a070a16e2179c9aa09379
+README.zh.md: 0e03bdc501102d34de66fab722fc0a4e84f24f1f

+ 5 - 5
apps/desktop/README.md

@@ -26,7 +26,7 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt
 |---|---|---|
 | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
 | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
-| Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
+| Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
 | Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
 | Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
@@ -37,7 +37,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
 
 ## Installation ownership
 
-Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same Electron Node-mode process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
+Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
 The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
@@ -49,7 +49,7 @@ Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-al
 
 ### Runtime and plugin activation
 
-The signed `resources/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
+The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
 1. The main window displays a local loading page before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
 2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
@@ -115,7 +115,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
 
 ### Runtime file selection
 
-Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
+Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
 
 The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 
@@ -206,7 +206,7 @@ pnpm run prepare:desktop
 
 This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
 
-Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
+Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
 
 An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
 

+ 5 - 5
apps/desktop/README.zh.md

@@ -26,7 +26,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 |---|---|---|
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
-| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
+| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
 | 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
 | 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
@@ -37,7 +37,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 
 ## 安装归属
 
-Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个 Electron Node 模式进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
+Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
 本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权。
 
@@ -49,7 +49,7 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 ### 运行时与插件激活
 
-签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
 1. 主窗口在 profile 准备或后端启动前显示本地加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
 2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
@@ -115,7 +115,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 ### 运行时文件筛选
 
-生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
+生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
 
 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 
@@ -206,7 +206,7 @@ pnpm run prepare:desktop
 
 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
 
-每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
+每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
 
 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
 

+ 9 - 4
apps/desktop/electron-builder.config.d.mts

@@ -4,11 +4,16 @@ export interface DesktopElectronBuilderConfig {
   readonly directories: {
     readonly output: string
   }
-  readonly extraResources: readonly [
-    { readonly from: string, readonly to: 'runtime' },
-    { readonly from: string, readonly to: 'dsh' },
-    { readonly from: string, readonly to: 'dsh/node_modules' },
+  readonly files: readonly [
+    string,
+    string,
+    string,
+    string,
+    { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] },
+    { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] },
   ]
+  readonly asarUnpack: readonly string[]
+  readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }]
   readonly mac: {
     readonly identity: string | undefined
     readonly forceCodeSigning: boolean

+ 11 - 21
apps/desktop/electron-builder.config.mjs

@@ -83,12 +83,18 @@ export function createElectronBuilderConfig(
       'lib/*.cjs',
       'renderer/**/*',
       'package.json',
+      { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
+      // electron-builder excludes a source directory's root node_modules.
+      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
+    ],
+    asarUnpack: [
+      '**/*.{node,dylib,dll,so,exe}',
+      '**/*.so.*',
+      '**/spawn-helper',
+      '**/@vscode/ripgrep/bin/rg',
     ],
     extraResources: [
       { from: buildPaths.runtime, to: 'runtime' },
-      { from: buildPaths.dsh, to: 'dsh' },
-      // electron-builder excludes a source directory's root node_modules.
-      { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' },
     ],
     mac: {
       icon: fileURLToPath(new URL('./resources/icon-macos.png', import.meta.url)),
@@ -96,8 +102,8 @@ export function createElectronBuilderConfig(
       identity: macOSSigning?.signingIdentity,
       forceCodeSigning: true,
       hardenedRuntime: true,
-      // Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
-      signIgnore: ['/Contents/Resources/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
+      // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
+      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
       notarize: true,
       target: ['dmg', 'zip'],
     },
@@ -105,24 +111,8 @@ export function createElectronBuilderConfig(
       sign: true,
       writeUpdateInfo: false,
     },
-    afterPack: async context => {
-      const { verifyDesktopRuntime, writeDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      const runtimeRoot = join(context.packager.getResourcesDir(context.appOutDir), 'dsh')
-      if (resolvedPlatform === 'win32' && !unsigned) {
-        // Windows signs copied executable resources before afterPack runs.
-        const prepared = await verifyDesktopRuntime(buildPaths.dsh,
-          context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
-        writeDesktopRuntime(runtimeRoot, prepared.release, prepared.sharedPackages.map(entry => entry.name),
-          { platform: resolvedPlatform, arch: resolvedArch })
-      }
-      await verifyDesktopRuntime(runtimeRoot,
-        context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
-    },
     afterSign: async context => {
       if (context.electronPlatformName !== 'darwin') return
-      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
-        context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch })
       verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
     },
     artifactBuildCompleted: artifact => {

+ 5 - 2
apps/desktop/src/host-process.ts

@@ -70,7 +70,8 @@ export class DesktopHostProcess {
    * @param inspectPort - Optional loopback inspector port for workspace development.
    * @param environment - Environment inherited by the Host and its plugin subprocesses.
    * @param onFailure - Receives the first unexpected child failure, including after readiness.
-   * @param primaryRuntime - Optional development payload location for bundled script dependencies.
+   * @param primaryRuntime - Optional payload location for bundled script dependencies.
+   * @param profileResolution - Package resolution mode for the application-owned profile.
    */
   constructor(
     private readonly node: string,
@@ -80,6 +81,7 @@ export class DesktopHostProcess {
     private readonly environment: NodeJS.ProcessEnv = process.env,
     private readonly onFailure?: (error: Error) => void,
     private readonly primaryRuntime?: string,
+    private readonly profileResolution: 'link' | 'runtime' = 'link',
   ) {}
 
   /**
@@ -95,7 +97,8 @@ export class DesktopHostProcess {
       entry,
       this.runtimeDir,
       this.projectDir,
-      ...(this.primaryRuntime === undefined ? [] : [this.primaryRuntime]),
+      this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
+      this.profileResolution,
     ], {
       cwd: this.projectDir,
       env: desktopNodeEnvironment(this.node, undefined, this.environment),

+ 4 - 2
apps/desktop/src/main.ts

@@ -79,7 +79,7 @@ function runtimeResources(): RuntimeResources {
     ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
       : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
   const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
-    ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(process.resourcesPath, 'dsh'))
+    ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh'))
   return { node, nodeBin, pnpm, dsh }
 }
 
@@ -238,7 +238,9 @@ async function main(): Promise<void> {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
       hostInspectPort, desktopNodeEnvironment(resources.node, resources.nodeBin, process.env), onFailure,
-      development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') : undefined)
+      development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
+        : join(process.resourcesPath, 'runtime', 'primary-runtime'),
+      development ? 'link' : 'runtime')
     return {
       start: async () => {
         const ready = await host.start()

+ 11 - 0
apps/desktop/tests/host-process.spec.ts

@@ -77,6 +77,17 @@ describe('desktop host process', () => {
     expect(failure).not.toHaveBeenCalled()
   })
 
+  it('passes external dependencies and runtime profile resolution to the Host', async () => {
+    const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
+      'primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
+    const primaryRuntime = join(runtime, 'external-primary-runtime')
+    const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
+      undefined, primaryRuntime, 'runtime')
+    hosts.push(host)
+    const { url } = await host.start()
+    expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime' })
+  })
+
   it('reports a fatal event after readiness once', async () => {
     const runtime = projectWithHost()
     const failure = vi.fn()

+ 15 - 61
apps/desktop/tests/macos-signature.spec.ts

@@ -1,10 +1,3 @@
-import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join, relative } from 'node:path'
-import { createRequire } from 'node:module'
-import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js'
-import { runtimeFixture } from './runtime-fixture.ts'
-import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import type { NotarizeOptions } from '@electron/notarize'
 import {
@@ -18,11 +11,6 @@ import {
   assertMacOSSignatureDetails,
 } from '../scripts/verify-macos-signature.mjs'
 
-// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter.
-const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as {
-  copyFiles: (matchers: FileMatcher[]) => Promise<void>
-}
-
 const RELEASE_ENVIRONMENT = {
   DSH_DESKTOP_APP_ID: 'com.example.desktop',
   DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
@@ -52,18 +40,28 @@ describe('desktop macOS release signature', () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
     expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
-    expect(config.extraResources).toHaveLength(3)
+    expect(config.extraResources).toHaveLength(1)
     expect(config.extraResources[0]?.to).toBe('runtime')
-    expect(config.extraResources[1]?.to).toBe('dsh')
     expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime')
-    expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh')
+    const [dshFiles, dshNodeModules] = config.files.slice(-2)
+    if (!dshFiles || !dshNodeModules || typeof dshFiles === 'string' || typeof dshNodeModules === 'string') {
+      throw new Error('desktop DSH resources must use electron-builder file mappings')
+    }
+    expect(portablePath(dshFiles.from)).toContain('/.desktop-build/targets/mac-arm64/dsh')
+    expect(dshFiles.to).toBe('dsh')
+    expect(portablePath(dshNodeModules.from)).toContain('/.desktop-build/targets/mac-arm64/dsh/node_modules')
+    expect(dshNodeModules.to).toBe('dsh/node_modules')
+    expect(config.asarUnpack).toEqual(expect.arrayContaining([
+      '**/*.{node,dylib,dll,so,exe}',
+      '**/@vscode/ripgrep/bin/rg',
+    ]))
     expect(config).toMatchObject({
       appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
       mac: {
         identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY,
         forceCodeSigning: true,
         notarize: true,
-        signIgnore: ['/Contents/Resources/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
+        signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
       },
       dmg: {
         sign: true,
@@ -83,9 +81,8 @@ describe('desktop macOS release signature', () => {
     const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path))
     expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true)
     expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true)
-    expect(ignored('/App.app/Contents/Resources/runtime/primary-runtime/dependencies/python/bin/python3')).toBe(true)
     for (const path of [
-      '/App.app/Contents/MacOS/DeepSeek Harness',
+      '/App.app/Contents/Resources/runtime/node/node',
       '/App.app/Contents/Resources/runtime/pnpm/addon.node',
       '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib',
       '/App.app/Contents/Frameworks/Electron.framework',
@@ -93,26 +90,6 @@ describe('desktop macOS release signature', () => {
     ]) expect(ignored(path)).toBe(false)
   })
 
-  it('copies the complete runtime despite electron-builder excluding root node_modules', async () => {
-    const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
-    const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
-    const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-'))
-    try {
-      const source = join(root, 'source')
-      const destination = join(root, 'resources')
-      runtimeFixture(source)
-      const sourceRoot = config.extraResources[1].from
-      const matchers = config.extraResources.slice(1).map(entry => new FileMatcher(
-        join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value,
-      ))
-      await copyFiles(matchers.slice(0, 1))
-      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u)
-      rmSync(destination, { recursive: true })
-      await copyFiles(matchers)
-      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } })
-    } finally { rmSync(root, { recursive: true, force: true }) }
-  })
-
   it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     expect(() => createElectronBuilderConfig({
@@ -121,24 +98,6 @@ describe('desktop macOS release signature', () => {
     }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
   })
 
-  it('copies primary interpreter files and nested pnpm modules through the runtime resource mapping', async () => {
-    const root = mkdtempSync(join(tmpdir(), 'desktop-primary-copy-'))
-    try {
-      const source = join(root, 'source')
-      const destination = join(root, 'runtime')
-      const files = ['primary-runtime/runtime.json', 'primary-runtime/dependencies/python/Lib/site-packages/numpy/native.pyd',
-        'primary-runtime/dependencies/pnpm/dist/node_modules/helper/index.js', 'primary-runtime/dependencies/node/node_modules/README.txt']
-      for (const file of files) {
-        mkdirSync(join(source, file, '..'), { recursive: true })
-        writeFileSync(join(source, file), 'payload')
-      }
-      mkdirSync(join(source, 'primary-runtime/dependencies/node/node_modules'), { recursive: true })
-      await copyFiles([new FileMatcher(source, destination, value => value)])
-      for (const file of files) expect(existsSync(join(destination, file))).toBe(true)
-      expect(existsSync(join(destination, 'primary-runtime/dependencies/node/node_modules'))).toBe(true)
-    } finally { rmSync(root, { recursive: true, force: true }) }
-  })
-
   it('isolates unsigned Windows artifacts and omits updater metadata without release credentials', async () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     const config = createElectronBuilderConfig({
@@ -148,11 +107,6 @@ describe('desktop macOS release signature', () => {
     }, 'win32', 'x64')
     expect(portablePath(config.directories.output)).toContain('/targets/win-x64/unsigned-artifacts')
     expect(portablePath(config.nsis.include)).toMatch(/\/scripts\/installer\.nsh$/u)
-    expect(config.nsis).toMatchObject({
-      oneClick: false, perMachine: false, allowElevation: false,
-      allowToChangeInstallationDirectory: false, installerLanguages: ['en_US', 'zh_CN'],
-    })
-    expect(config.nsis).not.toHaveProperty('script')
     expect(config).toMatchObject({
       win: { forceCodeSigning: false, signtoolOptions: { sign: undefined } },
       publish: null,

+ 4 - 1
apps/desktop/tests/main-startup.spec.ts

@@ -58,6 +58,7 @@ const harness = await vi.hoisted(async () => {
     constructor(
       readonly node: string, readonly runtime: string, readonly profile: string,
       readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void,
+      readonly primaryRuntime?: string, readonly profileResolution?: string,
     ) { hosts.push(this) }
   }
   const app = Object.assign(new EventEmitter(), {
@@ -386,7 +387,9 @@ describe('desktop main startup', () => {
     expect(harness.applyRelease).toHaveBeenCalledTimes(1)
     expect(harness.hosts[0]).toMatchObject({
       node: process.execPath,
-      runtime: join('desktop-test-resources', 'dsh'),
+      runtime: join(harness.app.getAppPath(), 'dsh'),
+      primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
+      profileResolution: 'runtime',
       profile: 'desktop-test-profile',
     })
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)

+ 66 - 0
apps/web/tests/diff-context.e2e.ts

@@ -0,0 +1,66 @@
+/** Cold Session rendering covers exact context and bounded whole-fragment replacements. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
+
+const ROOT = fileURLToPath(new URL('../../../snapshots', import.meta.url))
+const MODE = webSnapshotMode()
+
+const CASES = [
+  { name: 'diff-context', source: 'session/fs-edit/session.v3.jsonl', totals: '+1 -1', shared: 'level=info', inventory: ['ui.expected.md'] },
+  { name: 'diff-bounded', source: 'web/diff-bounded/session.v3.jsonl', totals: '+130 -130', shared: 'shared heading', inventory: ['session.v3.jsonl', 'ui.expected.md'] },
+]
+
+describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, await readFile(`${ROOT}/${scenario.source}`, 'utf8'), scenario.name)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      await scaffold?.close()
+    }
+  })
+
+  it('keeps collapsed and expanded counts consistent with the displayed diff', async () => {
+    onTestFailed(() => saveFailureShot(page, `web-e2e-${scenario.name}`))
+    const group = page.locator('[role="treeitem"]').first()
+    await group.waitFor({ timeout: 15_000 })
+    await group.click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
+    await expandTurnProcesses(page)
+    const edit = page.locator('[data-variant="edit"]')
+    expect(await edit.textContent()).toContain(scenario.totals)
+    expect(await edit.locator('[data-diff]').count()).toBe(0)
+    await edit.locator('[data-expandable]').click()
+    const card = edit.locator('[data-diff]')
+    await card.waitFor()
+    expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1)
+    expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`)
+    const snapshotDir = `${ROOT}/web/${scenario.name}`
+    await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`,
+      await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
+    await assertFixtureInventory(snapshotDir, scenario.inventory)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+})

+ 45 - 20
apps/web/tests/document-preview.e2e.ts

@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     }
   })
 
-  it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => {
+  it('opens text, isolated HTML, width-fitted images, and rendered PDF from the Session workspace', async () => {
     onTestFailed(async () => {
       await mkdir(SHOT_DIR, { recursive: true })
       await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
@@ -130,6 +130,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
         '</svg>',
       ].join('')),
       writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
+      writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])),
     ])
 
     const column = page.locator('[data-rightbar-col]')
@@ -175,6 +176,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
       await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
     }
+    // Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control.
     const viewer = preview.locator('[data-document-viewer-menu]')
     const body = preview.locator('[data-textpreview-body]')
     const sections = ['# Document preview']
@@ -276,9 +278,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     ].join('\n'))
 
     await openFile('smoke.pdf')
-    await expect.poll(() => viewer.innerText()).toBe('PDF')
     const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
     await canvas.waitFor({ state: 'visible', timeout: 30_000 })
+    expect(await viewer.count()).toBe(0)
     expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
     expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
     await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
@@ -308,7 +310,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     await successShot(page, 'pdf')
     sections.push([
       '## PDF', '',
-      `- Viewer: ${await viewer.innerText()}`,
+      `- Viewer menu hidden: ${String(await viewer.count() === 0)}`,
       `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
       `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
       `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
@@ -317,9 +319,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     ].join('\n'))
 
     await openFile('tiny.png')
-    await expect.poll(() => viewer.innerText()).toBe('Image')
     const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
     await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
+    expect(await viewer.count()).toBe(0)
     expect(await tinyImage.evaluate(node => ({
       width: (node as HTMLImageElement).naturalWidth,
       height: (node as HTMLImageElement).naturalHeight,
@@ -339,25 +341,35 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
 
     await openFile('large.svg')
     await expect.poll(() => viewer.innerText()).toBe('Image')
+    await viewer.click()
+    await page.getByRole('menuitem', { name: 'Plain text', exact: true }).waitFor({ timeout: 15_000 })
+    await page.keyboard.press('Escape')
     const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
     await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
-    expect(await largeImage.evaluate(node => ({
-      naturalWidth: (node as HTMLImageElement).naturalWidth,
-      naturalHeight: (node as HTMLImageElement).naturalHeight,
-      width: getComputedStyle(node).width,
-      height: getComputedStyle(node).height,
-    }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' })
-    expect(await body.evaluate(node => ({
-      horizontal: node.scrollWidth > node.clientWidth,
-      vertical: node.scrollHeight > node.clientHeight,
-    }))).toEqual({ horizontal: true, vertical: true })
+    const fitted = await largeImage.evaluate((node) => {
+      const image = node as HTMLImageElement
+      const scroller = image.closest('[data-textpreview-body]')
+      if (scroller === null) throw new Error('image document scroller is unavailable')
+      const rect = image.getBoundingClientRect()
+      return {
+        naturalWidth: image.naturalWidth,
+        naturalHeight: image.naturalHeight,
+        width: rect.width,
+        height: rect.height,
+        paneWidth: scroller.clientWidth,
+        paneHeight: scroller.clientHeight,
+      }
+    })
+    expect(fitted).toMatchObject({ naturalWidth: 1200, naturalHeight: 1600 })
+    expect(fitted.width).toBeLessThan(1200)
+    // Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds.
+    expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1)
+    expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2)
     const scrolled = await body.evaluate((node) => {
       node.scrollLeft = node.scrollWidth
-      node.scrollTop = node.scrollHeight
-      return { left: node.scrollLeft, top: node.scrollTop }
+      return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth }
     })
-    expect(scrolled.left).toBeGreaterThan(0)
-    expect(scrolled.top).toBeGreaterThan(0)
+    expect(scrolled).toEqual({ left: 0, horizontalOverflow: false })
     expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
 
     const releaseRead = Promise.withResolvers<undefined>()
@@ -450,12 +462,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     ].join('\n'))
 
     await openFile('notes.unknown')
-    await expect.poll(() => viewer.innerText()).toBe('Plain text')
     const plainLines = preview.locator('[data-textpreview-line]')
     await expect.poll(() => plainLines.count()).toBe(2)
+    // Plain text is the only candidate, so no viewer menu renders.
+    expect(await viewer.count()).toBe(0)
     const fallback = (await plainLines.allTextContents()).map(line => line.trim())
     expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.'])
-    sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
+    sections.push(['## Unknown suffix', '', `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
+
+    await filesTab.click()
+    await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click()
+    const unsupported = column.locator('[data-textpreview-state="unsupported"]')
+    await unsupported.waitFor({ timeout: 15_000 })
+    const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText()
+    expect(unsupportedLine).toContain('Preview is not available for this file type yet.')
+    expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4')
+    expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0)
+    expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0)
+    await successShot(page, 'unsupported')
+    sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n'))
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)

+ 30 - 0
apps/web/tests/expected/sidebar-terminal/colors.expected.md

@@ -0,0 +1,30 @@
+{
+  "lightText": {
+    "foreground": "rgb(111, 111, 110)",
+    "background": "rgba(0, 0, 0, 0)"
+  },
+  "customLight": {
+    "ansi": {
+      "foreground": "rgb(0, 137, 0)",
+      "background": "rgba(0, 0, 0, 0)"
+    },
+    "extended": {
+      "foreground": "rgb(153, 0, 153)",
+      "background": "rgba(0, 0, 0, 0)"
+    }
+  },
+  "ronLight": {
+    "background": "rgb(255, 255, 255)",
+    "foreground": "rgb(0, 0, 0)",
+    "shadow": "none",
+    "border": "rgb(0, 0, 0)",
+    "outline": "rgb(0, 0, 0)"
+  },
+  "whiteInDark": {
+    "background": "rgb(0, 0, 0)",
+    "foreground": "rgb(255, 255, 255)",
+    "shadow": "none",
+    "border": "rgb(255, 255, 255)",
+    "outline": "rgb(255, 255, 255)"
+  }
+}

+ 3 - 0
apps/web/tests/expected/sidebar-terminal/guide.expected.md

@@ -0,0 +1,3 @@
+- button "New terminal Run commands in the Session workspace"
+- button "Choose shell" [expanded]:
+  - img

+ 0 - 3
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -1,3 +0,0 @@
-- text: Shell
-- button "Shell": bash — /bin/bash
-- button "Start terminal"

+ 3 - 3
apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md

@@ -1,5 +1,5 @@
 - menu:
-  - menuitem "bash — /bin/bash":
-    - text: bash — /bin/bash
+  - menuitem "bash":
+    - text: bash
     - img
-  - menuitem "sh — /bin/sh"
+  - menuitem "sh"

+ 14 - 0
apps/web/tests/expected/sidebar-terminal/theme.expected.md

@@ -0,0 +1,14 @@
+{
+  "light": {
+    "surface": "rgb(255, 255, 255)",
+    "viewport": "rgb(255, 255, 255)",
+    "underlay": "rgb(255, 255, 255)",
+    "foreground": "rgb(15, 17, 21)"
+  },
+  "dark": {
+    "surface": "rgb(21, 21, 23)",
+    "viewport": "rgb(21, 21, 23)",
+    "underlay": "rgb(21, 21, 23)",
+    "foreground": "rgb(249, 250, 251)"
+  }
+}

+ 41 - 5
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -23,7 +23,7 @@ import {
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import {
-  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
+  connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
@@ -291,10 +291,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
         await input.press('Enter')
         if (MODE !== 'record') {
           const thinking = page.locator('[data-variant="think"][data-state="running"]')
-          const disclosure = thinking.getByRole('button')
-          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
-          await disclosure.click()
-          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+          await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
           const liveTail = thinking.locator('[data-follow-end]')
           await expect.poll(async () => {
             if (await liveTail.count() !== 1) return false
@@ -346,6 +343,45 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('pins an open Think header to the conversation scrollport (real layout)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-think-sticky'))
+    // The settled turn collapses its process row, which hides the Think row.
+    // The fixture's recorded reasoning is one line, too short to overflow the
+    // scrollport, so this case proves the CSS resolves onto the Think header in
+    // a real browser (jsdom computes no sticky layout); the pinned-while-
+    // scrolling and z-rank evidence belongs to the compaction path in
+    // seeded-history.e2e.ts, whose summary length that suite controls.
+    const thinkRow = page.locator('[data-variant="think"]').first()
+    await thinkRow.waitFor({ state: 'attached', timeout: 15_000 })
+    const process = page.locator('[data-turn-process]').first()
+    const processWasOpen = await process.getAttribute('aria-expanded') === 'true'
+    try {
+      await expandOwningTurnProcess(page, thinkRow)
+      const collapsedHeader = thinkRow.locator('[data-disclosure-row]').first()
+      await collapsedHeader.waitFor({ timeout: 10_000 })
+      // Collapsed, the rule's `data-open` gate is absent and the header stays in
+      // flow. It is `relative` here — the row is the sweep-glare overlay anchor
+      // — so the assertion is the absence of `sticky`, not a specific value.
+      expect(await collapsedHeader.evaluate(element => getComputedStyle(element).position)).not.toBe('sticky')
+      await collapsedHeader.click()
+      const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]').first()
+      await openHeader.waitFor({ timeout: 10_000 })
+      const openStyle = await openHeader.evaluate((element) => {
+        const style = getComputedStyle(element)
+        return { position: style.position, top: style.top }
+      })
+      expect(openStyle.position).toBe('sticky')
+      expect(openStyle.top).toBe('0px')
+    } finally {
+      // Restore the settled state the reload goldens below are captured in.
+      const openThinkRow = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]')
+      if (await openThinkRow.count() > 0) await openThinkRow.first().click()
+      if (!processWasOpen && await process.getAttribute('aria-expanded') === 'true') await process.click()
+    }
+    await expect.poll(() => page.locator('[data-variant="think"] [data-open]').count(), { timeout: 5_000 }).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
     const warningStart = tripwire.warnings.length

+ 23 - 15
apps/web/tests/scaffold.ts

@@ -59,9 +59,12 @@ import {
 import {
   auditStartupEntries,
   composeEntries,
+  createProfileResolutionGeneration,
   healProfilesModuleFallback,
   loadOverlayPatches,
+  PluginPackages,
   type Profile,
+  type ProfileResolutionMode,
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
@@ -288,6 +291,8 @@ export interface WebScaffold {
 
 /** Options for {@link launchWebScaffold}. */
 export interface LaunchOptions {
+  /** Profile resolver backend used by this test Host; defaults to runtime coverage. */
+  profileResolutionMode?: Extract<ProfileResolutionMode, 'dual' | 'runtime'>
   /** Enable the real Open In rows with deterministic launch-environment facts. */
   openInAppEnvironment?: LaunchEnvironmentSnapshot
   /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */
@@ -521,6 +526,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   const patches: PatchOptions[] = [
     ...basePatches,
     ...surfacePatches,
+    { id: 'session-log-deepseek', config: { enabled: false } },
     // The historical Messages fixture retains its recorded route during replay;
     // live configuration uses the shared DeepSeek route. Explicit overlays win.
     ...messages
@@ -683,21 +689,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         patches: [],
       }
     }))
-    // Mirror the production launcher: the shared installation closure keeps
-    // its carrier-specific fallback, while private bundle dependencies stay
-    // isolated to this synthetic scaffold profile.
-    await healProfilesModuleFallback({
-      installAnchor: INSTALL_ANCHOR,
-      home: harnessHome,
-      profile: {
-        name: 'scaffold',
-        dir: profileDir,
-        layers: extraLayers,
-        patchPath: join(profileDir, 'cordis.patch.yml'),
-        patches: [],
-        patchReload: 'startup',
-      },
-    })
+    const profile: Profile = {
+      name: 'scaffold',
+      dir: profileDir,
+      layers: extraLayers,
+      patchPath: join(profileDir, 'cordis.patch.yml'),
+      patches: [],
+      patchReload: 'startup',
+    }
+    const profileResolutionMode = options.profileResolutionMode ?? 'runtime'
+    const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }
+    const resolution = profileResolutionMode === 'runtime'
+      ? await createProfileResolutionGeneration(resolutionOptions)
+      : await healProfilesModuleFallback(resolutionOptions)
     await mkdir(profileDir, { recursive: true })
     const rootConfig = join(profileDir, 'cordis.yml')
     await writeFile(rootConfig, '[]\n')
@@ -714,6 +718,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
       },
     })
+    await ctx.plugin(PluginPackages, {
+      generation: resolution,
+      behavior: profileResolutionMode === 'dual' ? 'verify' : 'enforce',
+    })
     await ctx.plugin(Loader)
     ctx.loader.builtins.include = Include
     // `cordis:group` beside it, exactly as `boot()` registers it: a group row is

+ 201 - 12
apps/web/tests/seeded-history.e2e.ts

@@ -39,6 +39,13 @@ const UI_EXPANDED_EXPECTED = fileURLToPath(
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
 const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md')
+// The pinned-header geometry golden: a pure-CSS, user-visible behavior that
+// changes no DOM and no accessible name, so the aria goldens cannot capture it
+// (docs/testing.md, "when a snapshot test is required", still requires a
+// keyless snapshot). Following composer-draft-scroll's geometry golden, it
+// records platform-independent semantic booleans about the pinned compaction
+// header, no absolute pixels.
+const STICKY_GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'sticky-geometry.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'seeded-history-web-e2e'
 
@@ -137,7 +144,16 @@ function withCompaction(raw: string, meter: TokenMeter): string {
       sourceCommandId: commandId,
       summary: [{
         type: 'text',
-        text: '## Cold resume compact summary\n\n- The exact summary remains available.',
+        text: '## Cold resume compact summary\n\n- The exact summary remains available.\n\n'
+          // A fenced code block gives the summary body a sticky-bannered
+          // descendant (CodeBlock pins its banner at z-index 6). The block is
+          // long enough that its banner has room to hold below the pinned
+          // header, which is where its Copy control must stay clickable; the
+          // list makes the body overflow the shrunk viewport.
+          + '```ts\nfunction resume(): boolean {\n'
+          + Array.from({ length: 26 }, (_, index) => `  const step${index + 1} = read(${index + 1})`).join('\n')
+          + '\n  return true\n}\n```\n\n'
+          + Array.from({ length: 40 }, (_, index) => `- Retained fact ${index + 1}: the reader still sees the pre-compaction surface.`).join('\n'),
       }],
       shadowedRange: { start: first, end: last },
       shadowedSeqs: surfaceSeqs,
@@ -437,20 +453,193 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
   })
 
-  it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
+  it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
     const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
     await marker.waitFor({ timeout: 10_000 })
     expect(await marker.getAttribute('aria-expanded')).toBe('false')
-    await marker.click()
-    await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
-    await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
-      timeout: 5_000,
-    }).toBe(1)
-    expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
-    // Restore the shared page state for any later case.
-    await marker.click()
-    await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+    // Collapsed, the marker is not pinned: the sticky rule's `:has()` gate
+    // needs the body sibling, which only exists while open. jsdom computes no
+    // sticky layout, so this real-browser layer proves the CSS resolves.
+    const collapsedPosition = await marker.evaluate(element => getComputedStyle(element).position)
+    expect(collapsedPosition).not.toBe('sticky')
+    const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
+    // Captured so a failure in the cleanup below cannot replace the assertion
+    // that actually failed.
+    let bodyError: unknown
+    try {
+      await marker.click()
+      await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
+      await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
+        timeout: 5_000,
+      }).toBe(1)
+      expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
+      // Open, the toggle pins to the scroll container's top.
+      const openStyle = await marker.evaluate((element) => {
+        const style = getComputedStyle(element)
+        return { position: style.position, top: style.top, zIndex: Number.parseInt(style.zIndex, 10) }
+      })
+      expect(openStyle.position).toBe('sticky')
+      expect(openStyle.top).toBe('0px')
+      // The summary body carries a fenced code block whose own banner pins at
+      // z-index 6; the toggle must outrank it, or a code-block summary would
+      // re-bury the toggle. Sample the banner inside THIS summary body, not a
+      // code block elsewhere on the page.
+      const bannerZ = await page.locator('[class*="compactionBody"] [class*="bannerWrap"]').first().evaluate(
+        element => Number.parseInt(getComputedStyle(element).zIndex, 10),
+      )
+      expect(openStyle.zIndex).toBeGreaterThan(bannerZ)
+      // Hovering the open toggle must keep an OPAQUE fill: the default hover
+      // token is translucent and would let the scrolling prose bleed through
+      // the moment the pointer lands to collapse it. The alpha token, if
+      // present, is the fourth comma value (`rgba(r, g, b, a)`) or the value
+      // after `/` in the space form; three channels mean opaque. A color that
+      // parses to neither returns -1, which fails loud instead of passing as
+      // opaque.
+      await marker.hover()
+      const hoverAlpha = await marker.evaluate((element) => {
+        const bg = getComputedStyle(element).backgroundColor
+        const inner = /^rgba?\((.+)\)$/.exec(bg.trim())?.[1]
+        if (inner === undefined) return -1
+        const slashAlpha = inner.split('/')[1]
+        if (slashAlpha !== undefined) return Number.parseFloat(slashAlpha)
+        const channels = inner.split(/[\s,]+/).filter(token => token.length > 0)
+        const commaAlpha = channels[3]
+        if (commaAlpha !== undefined) return Number.parseFloat(commaAlpha)
+        if (channels.length === 3) return 1
+        return -1
+      })
+      expect(hoverAlpha).toBe(1)
+      // Scroll so the summary's code banner reaches its own stuck position.
+      // The banner's sticky offset holds it below the pinned header's band, so
+      // the point this case samples is the banner's Copy control: the header
+      // must not cover it. Shrinking the viewport first forces overflow
+      // regardless of summary length.
+      await page.setViewportSize({ width: originalViewport.width, height: 360 })
+      const geom = await marker.evaluate((button) => {
+        const container = button.closest('[data-conversation-scroll]') as HTMLElement
+        const banner = container.querySelector('[class*="compactionBody"] [class*="bannerWrap"]') as HTMLElement
+        const copy = banner.querySelector('button') as HTMLElement
+        // Both the toggle and the code banner are sticky, so a rect taken while
+        // either is stuck reports the stuck position rather than its content
+        // offset. Measure both unstuck, so the target below does not depend on
+        // where the scrollport happened to be when this case started.
+        const markerInline = button.style.position
+        const bannerInline = banner.style.position
+        const bannerTopInline = banner.style.top
+        button.style.position = 'static'
+        banner.style.position = 'static'
+        banner.style.top = 'auto'
+        const containerTop = container.getBoundingClientRect().top
+        const markerStaticTop = button.getBoundingClientRect().top - containerTop + container.scrollTop
+        const bannerStaticTop = banner.getBoundingClientRect().top - containerTop + container.scrollTop
+        const headerHeight = button.getBoundingClientRect().height
+        button.style.position = markerInline
+        banner.style.position = bannerInline
+        banner.style.top = bannerTopInline
+        // The banner sticks once its static top passes the band the toggle
+        // occupies. Land the static top 8px above the scrollport top: if the
+        // banner still pinned at top 0, 8px of it would sit under the toggle,
+        // so this position distinguishes the offset from the uncovered case.
+        // The banner must hold at the band's bottom edge, and its Copy control
+        // must stay the topmost element at its own center.
+        container.scrollTop = Math.max(0, bannerStaticTop + 8)
+        const markerRect = button.getBoundingClientRect()
+        const bannerRect = banner.getBoundingClientRect()
+        const copyRect = copy.getBoundingClientRect()
+        const currentContainerTop = container.getBoundingClientRect().top
+        const markerProbe = document.elementFromPoint(
+          markerRect.left + markerRect.width / 2,
+          markerRect.top + markerRect.height / 2,
+        )
+        const copyProbe = document.elementFromPoint(
+          copyRect.left + copyRect.width / 2,
+          copyRect.top + copyRect.height / 2,
+        )
+        return {
+          scrollTop: container.scrollTop,
+          // The header's own content offset now lies above the scrollport top,
+          // so its rect top can equal the scrollport top only through stickiness
+          // — this is the precondition that makes the pinning assertion mean
+          // something.
+          staticAboveViewport: container.scrollTop > markerStaticTop,
+          markerTop: markerRect.top,
+          containerTop: currentContainerTop,
+          bannerTop: bannerRect.top,
+          bannerStuck: Math.abs(bannerRect.top - (currentContainerTop + headerHeight)) <= 1,
+          bannerBelowHeader: bannerRect.top >= markerRect.bottom - 1,
+          markerOwnsCenter: button.contains(markerProbe),
+          copyOwnsCenter: copy.contains(copyProbe),
+        }
+      })
+      expect(geom.scrollTop).toBeGreaterThan(0)
+      expect(geom.staticAboveViewport).toBe(true)
+      expect(Math.abs(geom.markerTop - geom.containerTop)).toBeLessThanOrEqual(1)
+      expect(geom.bannerStuck).toBe(true)
+      expect(geom.bannerBelowHeader).toBe(true)
+      expect(geom.markerOwnsCenter).toBe(true)
+      expect(geom.copyOwnsCenter).toBe(true)
+      // Keyless geometry golden for this user-visible, DOM-invariant CSS
+      // behavior: platform-independent semantic facts, no absolute pixels.
+      // Every line is a value asserted just above, so a regression reddens the
+      // expect first; compareOrRefreshGolden writes the file in refresh mode
+      // and byte-compares it in replay.
+      const stickyGolden = [
+        '# Compaction marker sticky header (pinned over a code-block summary)',
+        '',
+        '## Collapsed',
+        '',
+        `- header is not sticky: ${String(collapsedPosition !== 'sticky')}`,
+        '',
+        '## Open, pinned at the scroll container top',
+        '',
+        `- header position is sticky: ${String(openStyle.position === 'sticky')}`,
+        `- header pins to the top edge: ${String(openStyle.top === '0px')}`,
+        `- header outranks the summary code-block banner: ${String(openStyle.zIndex > bannerZ)}`,
+        `- hover fill stays fully opaque: ${String(hoverAlpha === 1)}`,
+        '',
+        '## Scrolled so the summary code banner reaches its sticky offset',
+        '',
+        `- container is scrolled off its top: ${String(geom.scrollTop > 0)}`,
+        `- header's static position sits above the scrollport: ${String(geom.staticAboveViewport)}`,
+        `- header holds at the scrollport top: ${String(Math.abs(geom.markerTop - geom.containerTop) <= 1)}`,
+        `- header owns the center point (toggle stays clickable): ${String(geom.markerOwnsCenter)}`,
+        `- summary code banner holds below the header band: ${String(geom.bannerStuck)}`,
+        `- summary code banner stays clear of the header: ${String(geom.bannerBelowHeader)}`,
+        `- banner Copy control owns its own center: ${String(geom.copyOwnsCenter)}`,
+      ].join('\n').trimEnd()
+      await compareOrRefreshGolden(STICKY_GEOMETRY_EXPECTED, stickyGolden, MODE)
+    } catch (error) {
+      bodyError = error
+    }
+    // Restore the shared page state whether or not the body failed. Order
+    // matters: collapse the marker, restore the viewport, then re-enter
+    // follow-bottom. The control is what clears the off-floor ownership a
+    // programmatic `scrollTop` assignment leaves in ChatView's reader-movement
+    // ledger, so click it when it is there. It appears only after that ledger
+    // settles (`scrollend` or the sampling interval), and it never renders at
+    // all when the collapse's shrink clamp already re-entered follow, so the
+    // assertion is the restored state — no control, and the scrollport on its
+    // floor — rather than the control's presence.
+    try {
+      if (await marker.getAttribute('aria-expanded') === 'true') await marker.click()
+      await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+      await page.setViewportSize(originalViewport)
+      const scrollport = page.locator('[data-conversation-scroll]')
+      const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
+      await expect.poll(async () => {
+        if (await backToBottom.count() > 0) await backToBottom.click()
+        const atFloor = await scrollport.evaluate((host: HTMLElement) =>
+          Math.abs(host.scrollHeight - host.clientHeight - host.scrollTop) <= 1)
+        return await backToBottom.count() === 0 && atFloor
+      }, { timeout: 15_000 }).toBe(true)
+    } catch (cleanupError) {
+      // The body's own assertion is the diagnosis; a cleanup failure would
+      // replace it, and the state it failed to restore shows up in the next
+      // case's golden.
+      if (bodyError === undefined) throw cleanupError
+    }
+    if (bodyError !== undefined) throw bodyError
   })
 
   it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
@@ -546,7 +735,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
-      'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+      'session.v3.jsonl', 'sticky-geometry.expected.md', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })
 })

+ 5 - 3
apps/web/tests/shipped-composition.e2e.ts

@@ -2,7 +2,7 @@
 // and asserts its catalog, defaults, Loader lifecycle, and one complete Auto
 // producer-to-tool path. Browser scenarios in this lane own visual behavior.
 import { randomUUID } from 'node:crypto'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -513,6 +513,7 @@ afterEach(async () => {
 
 it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
   scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+  expect(existsSync(join(scaffold.harnessHome, 'profiles', 'node_modules'))).toBe(false)
   const ctx = scaffold.ctx
   expect(ctx.llm.listProviders().some(provider => provider.id === 'deepseek-messages')).toBe(false)
   expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
@@ -640,8 +641,9 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
   }
 }, 120_000)
 
-it('ships PTC with run_code but without the general workflow SDK binding', async () => {
-  scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+it('ships PTC with run_code but without the general workflow SDK binding under dual resolution', async () => {
+  scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, profileResolutionMode: 'dual' })
+  expect(existsSync(join(scaffold.harnessHome, 'profiles', 'node_modules'))).toBe(true)
   const ctx = scaffold.ctx
   const handle = await ctx.agents.create({
     sessionId: SessionId('shipped-ptc-composition'),

+ 176 - 21
apps/web/tests/sidebar-terminal.e2e.ts

@@ -18,8 +18,7 @@ async function openTerminal(page: Page, waitForShell = true): Promise<void> {
   if (await expand.isVisible()) await expand.click()
   const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
   if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
-  await entry.click()
-  await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+  await entry.getByRole('button', { name: /^New terminal/u }).click()
   if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
 }
 
@@ -29,6 +28,18 @@ async function command(page: Page, text: string): Promise<void> {
   await page.keyboard.press('Enter')
 }
 
+async function selectTerminalTheme(page: Page, name: string): Promise<void> {
+  await page.getByRole('button', { name: 'Settings', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: 'Settings' })
+  const [response] = await Promise.all([
+    page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
+    dialog.getByRole('button', { name, exact: true }).click(),
+  ])
+  expect(response.ok()).toBe(true)
+  await page.keyboard.press('Escape')
+  await dialog.waitFor({ state: 'hidden' })
+}
+
 describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -81,6 +92,139 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     }
   })
 
+  it('preserves program palettes and keeps ANSI text and cursors readable across DSH themes', async () => {
+    onTestFailed(() => saveFailureShot(page, 'terminal-colors'))
+    await page.emulateMedia({ colorScheme: 'light' })
+    await openTerminal(page)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    const screen = page.locator('.xterm-rows:visible')
+    await command(page, "PS1=''; printf '\\033c\\033[97mBRIGHT_WHITE\\033[0m\\n'")
+    const colorOf = async (text: string) => {
+      const cell = screen.getByText(text, { exact: true })
+      await cell.waitFor()
+      return cell.evaluate(element => ({
+        foreground: getComputedStyle(element).color, background: getComputedStyle(element).backgroundColor,
+      }))
+    }
+    const lightText = await colorOf('BRIGHT_WHITE')
+    // Compare the rendered glyph with its real surface; the raw ANSI palette remains untouched.
+    expect(contrastRatio(lightText.foreground, 'rgb(255, 255, 255)')).toBeGreaterThanOrEqual(4.5)
+    await command(page, "printf '\\033]4;1;#009900;255;#990099\\007\\033[31mANSI_CUSTOM\\033[38;5;255mEXTENDED_CUSTOM\\033[0m\\n'")
+    const customLight = { ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }
+    await selectTerminalTheme(page, 'Dark')
+    await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(249, 250, 251)')
+    await selectTerminalTheme(page, 'Light')
+    await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(15, 17, 21)')
+    expect({ ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }).toEqual(customLight)
+    await command(page, "printf '\\033]104;1;255\\007'")
+    await expect.poll(async () => (await colorOf('ANSI_CUSTOM')).foreground).not.toBe(customLight.ansi.foreground)
+    await expect.poll(async () => (await colorOf('EXTENDED_CUSTOM')).foreground).not.toBe(customLight.extended.foreground)
+
+    await command(page, "printf '\\033]10;#112233;#ddeeff;#990099\\007'")
+    const defaults = () => terminal.evaluate(root => ({
+      foreground: getComputedStyle(root.querySelector('.xterm-rows')!).color,
+      background: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
+    }))
+    const applicationDefaults = { foreground: 'rgb(17, 34, 51)', background: 'rgb(221, 238, 255)' }
+    await expect.poll(defaults).toEqual(applicationDefaults)
+    await selectTerminalTheme(page, 'Dark')
+    await expect.poll(() => terminal.evaluate(root => getComputedStyle(root.querySelector('.xterm')!.parentElement!).backgroundColor))
+      .toBe('rgb(21, 21, 23)')
+    expect(await defaults()).toEqual(applicationDefaults)
+    await command(page, "printf '\\033]110\\007\\033]111\\007\\033]112\\007'")
+    await expect.poll(defaults).toEqual({ foreground: 'rgb(249, 250, 251)', background: 'rgb(21, 21, 23)' })
+    await selectTerminalTheme(page, 'Light')
+    await expect.poll(defaults).toEqual({ foreground: 'rgb(15, 17, 21)', background: 'rgb(255, 255, 255)' })
+
+    const cursorColors = () => screen.locator('.xterm-cursor').evaluate((cursor) => {
+      const style = getComputedStyle(cursor)
+      return {
+        background: style.backgroundColor, foreground: style.color,
+        shadow: style.boxShadow, border: style.borderBottomColor, outline: style.outlineColor,
+      }
+    })
+    const paintCursor = async (sgr: string, shape = 2) => {
+      await command(page, `printf '\\033[0m\\033[2J\\033[H\\033[${sgr}mCURSOR\\033[1G\\033[${shape} q'`)
+      await expect.poll(() => screen.locator('.xterm-cursor').innerText()).toBe('C')
+    }
+    // ron uses foreground 51 and background 16; no installed Vim is required by CI.
+    await paintCursor('38;5;51;48;5;16')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
+    const ronLight = await cursorColors()
+    expect(ronLight.foreground).toBe('rgb(0, 0, 0)')
+    await terminal.screenshot({ path: `${shots}/ron-light.png`, animations: 'disabled' })
+    await selectTerminalTheme(page, 'Dark')
+    await page.locator('.xterm-helper-textarea:visible').click()
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(249, 250, 251)')
+    await paintCursor('38;2;0;0;0;48;2;255;255;255')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    const whiteInDark = await cursorColors()
+    await paintCursor('0;7')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    for (const shape of [4, 6]) {
+      await paintCursor('38;5;51;48;5;16', shape)
+      await expect.poll(async () => shape === 4 ? (await cursorColors()).border : (await cursorColors()).shadow).toContain('rgb(249, 250, 251)')
+    }
+    await paintCursor('38;2;0;0;0;48;2;255;255;255', 1)
+    await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.1s !important; animation-play-state: paused !important; }' })
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.6s !important; }' })
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
+    await page.locator('.xterm-helper-textarea:visible').evaluate((element) =>{  element.blur() })
+    await expect.poll(async () => (await cursorColors()).outline).toBe('rgb(0, 0, 0)')
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/colors.expected.md', import.meta.url)),
+      JSON.stringify({ lightText, customLight, ronLight, whiteInDark }, null, 2), webSnapshotMode())
+    expect(handles).toHaveLength(1)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('follows light, dark and system themes while preserving the running shell and its output', async () => {
+    await page.emulateMedia({ colorScheme: 'light' })
+    await openTerminal(page)
+    const process = processIdentity(0)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    const screen = page.locator('.xterm-rows:visible')
+    await command(page, "DSH_THEME_PROBE=retained; PS1=''; printf '\\033cTHEME_CONTENT_RETAINED\\n'")
+    await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
+    const readColors = () => terminal.evaluate((root) => {
+      const xterm = root.querySelector('.xterm')!
+      const rows = root.querySelector('.xterm-rows')!
+      return {
+        surface: getComputedStyle(xterm.parentElement!).backgroundColor,
+        viewport: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
+        underlay: getComputedStyle(root.querySelector('.xterm-viewport')!).backgroundColor,
+        foreground: getComputedStyle(rows).color,
+      }
+    })
+
+    const light = await readColors()
+    expect(light.viewport).toBe(light.surface)
+    expect(light.underlay).toBe(light.surface)
+    await terminal.screenshot({ path: `${shots}/theme-light.png`, animations: 'disabled' })
+    await selectTerminalTheme(page, 'Dark')
+    await expect.poll(async () => (await readColors()).viewport).not.toBe(light.viewport)
+    const dark = await readColors()
+    expect(dark.viewport).toBe(dark.surface)
+    expect(dark.underlay).toBe(dark.surface)
+    expect(dark.foreground).not.toBe(light.foreground)
+    await terminal.screenshot({ path: `${shots}/theme-dark.png`, animations: 'disabled' })
+    await selectTerminalTheme(page, 'Light')
+    await expect.poll(readColors).toEqual(light)
+    await selectTerminalTheme(page, 'System')
+    await page.emulateMedia({ colorScheme: 'dark' })
+    await expect.poll(readColors).toEqual(dark)
+    await page.emulateMedia({ colorScheme: 'light' })
+    await expect.poll(readColors).toEqual(light)
+    await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
+    await command(page, 'printf "THEME_STATE:%s\\n" "$DSH_THEME_PROBE"')
+    await expect.poll(() => screen.innerText()).toContain('THEME_STATE:retained')
+    expect(handles).toHaveLength(1)
+    expect(alive(process)).toBe(true)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/theme.expected.md', import.meta.url)),
+      JSON.stringify({ light, dark }, null, 2), webSnapshotMode())
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
   it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
     await openTerminal(page)
@@ -158,42 +302,43 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     expect(tripwire.pageErrors).toEqual([])
   })
 
-  it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
+  it('chooses a shell from the guide menu, opens it directly, and remembers it after reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
     await page.locator('[data-sidebar-right-expand]').click()
     const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
-    expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace')
     await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
-    await entry.click()
-    const selector = page.getByRole('button', { name: 'Shell', exact: true })
-    await selector.waitFor()
-    expect(await selector.innerText()).toContain('bash — /bin/bash')
-    expect(handles).toHaveLength(0)
-    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
-      await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
+    const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
+    const cardBox = (await entry.boundingBox())!
+    const triggerBox = (await selector.boundingBox())!
+    expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
+    await page.emulateMedia({ colorScheme: 'dark' })
     await selector.click()
+    await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
+    expect(handles).toHaveLength(0)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/guide.expected.md', import.meta.url)),
+      await entry.ariaSnapshot(), webSnapshotMode())
     await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
       await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
     await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
-    await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click()
+    await page.keyboard.press('Escape')
+    expect(handles).toHaveLength(0)
+    await selector.click()
+    await page.getByRole('menuitem', { name: 'sh', exact: true }).click()
     expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
-    await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
-    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await page.locator('.xterm-helper-textarea:visible').waitFor()
     await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
     const screen = page.locator('.xterm-rows:visible')
     await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
-    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
     const retained = processIdentity(0)
     await page.reload({ waitUntil: 'load' })
     await page.locator('.xterm-helper-textarea:visible').waitFor()
-    expect(await selector.count()).toBe(0)
     expect(alive(retained)).toBe(true)
     await page.locator('[data-dockkit-add-tab]').click()
-    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
-    await selector.waitFor()
-    expect(await selector.innerText()).toContain('sh — /bin/sh')
-    expect(handles).toHaveLength(1)
-    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await selector.click()
+    await page.getByRole('menuitem', { name: 'sh', exact: true }).waitFor()
+    expect(await page.getByRole('menuitem', { name: 'sh', exact: true }).locator('svg').count()).toBe(1)
+    await page.keyboard.press('Escape')
+    await entry.getByRole('button', { name: /^New terminal/u }).click()
     await expect.poll(() => handles.length).toBe(2)
     expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
     expect(tripwire.pageErrors).toEqual([])
@@ -225,3 +370,13 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   })
 
 })
+
+function contrastRatio(first: string, second: string): number {
+  const luminance = (color: string) => {
+    const [r, g, b] = color.match(/[\d.]+/gu)!.map(Number).map(channel => channel / 255)
+      .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
+    return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
+  }
+  const a = luminance(first), b = luminance(second)
+  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
+}

+ 12 - 3
apps/web/tests/turn-tail-actions.e2e.ts

@@ -17,7 +17,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
 import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -201,13 +201,22 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
     await page.keyboard.press('Escape')
     await trigger.click()
 
     const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
+
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(() => timeTrigger.count(), { timeout: 15_000 }).toBe(1)
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await timeTrigger.click()
+    expect(await timeDialog.count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)

+ 1 - 0
apps/web/tsconfig.json

@@ -23,6 +23,7 @@
   // cannot see both sides of the cordis Context merges).
   "exclude": [
     "tests/default-product-isolation.e2e.ts",
+    "tests/diff-context.e2e.ts",
     "tests/scaffold.ts",
     "tests/auto-review-fixture.ts",
     "tests/scaffold-generation.spec.ts",

+ 3 - 0
benchmarks/package.json

@@ -6,6 +6,9 @@
   "type": "module",
   "devDependencies": {
     "playwright": "^1.49.0",
+    "@xterm/headless": "^6.0.0",
+    "@deepseek-ai/dsh-terminal": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-llm-replay": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",

+ 2 - 0
benchmarks/terminal-io/session-adapter.ts

@@ -0,0 +1,2 @@
+/** Private production entry bundled into the plain-Node terminal I/O worker. */
+export { LocalPtySession } from '../../packages/terminal/terminal-bash/src/session.ts'

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