فهرست منبع

Merge remote-tracking branch 'origin/master' into HEAD

lsdsjy 1 ماه پیش
والد
کامیت
05373a4897
100فایلهای تغییر یافته به همراه1081 افزوده شده و 249 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml
  11. 35 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
  12. 35 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md
  13. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml
  14. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
  15. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md
  16. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  17. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  18. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  19. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml
  20. 31 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md
  21. 31 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
  23. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
  24. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
  25. 2 2
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
  26. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
  27. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
  28. 6 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml
  29. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md
  30. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md
  31. 6 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml
  32. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md
  33. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md
  34. 2 2
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml
  35. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
  36. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md
  37. 2 2
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml
  38. 2 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
  39. 2 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md
  40. 2 3
      .github/workflows/ci-master.yml
  41. 1 1
      apps/cli/package.json
  42. 1 0
      apps/cli/src/profile-boot.ts
  43. 10 11
      apps/cli/tests/built-bin.e2e.ts
  44. 1 1
      apps/cli/tests/fixtures/invalid-provider.cordis.yml
  45. 4 2
      apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt
  46. 1 7
      apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml
  47. 16 8
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  48. 12 4
      apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts
  49. 8 4
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  50. 2 1
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  51. 350 0
      apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
  52. 1 1
      apps/desktop-host/package.json
  53. 1 1
      apps/desktop/package.json
  54. 1 1
      apps/web/package.json
  55. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  56. 9 4
      apps/web/tests/feedback-release.e2e.ts
  57. 25 14
      apps/web/tests/message-feedback.e2e.ts
  58. 67 0
      apps/web/tests/present.e2e.ts
  59. 31 0
      apps/web/tests/produced-files.e2e.ts
  60. 1 1
      apps/web/tests/question-composer.e2e.ts
  61. 2 2
      apps/web/tests/scaffold.ts
  62. 2 2
      docs/config-catalog.i18n.yaml
  63. 6 2
      docs/config-catalog.md
  64. 6 2
      docs/config-catalog.zh.md
  65. 2 2
      docs/cordis-api/fiber.i18n.yaml
  66. 3 3
      docs/cordis-api/fiber.md
  67. 3 3
      docs/cordis-api/fiber.zh.md
  68. 2 2
      docs/event-producer-consumer.i18n.yaml
  69. 1 0
      docs/event-producer-consumer.md
  70. 1 0
      docs/event-producer-consumer.zh.md
  71. 2 2
      docs/rescope.i18n.yaml
  72. 1 1
      docs/rescope.md
  73. 1 1
      docs/rescope.zh.md
  74. 2 2
      docs/subsystems/attachment.i18n.yaml
  75. 17 7
      docs/subsystems/attachment.md
  76. 17 7
      docs/subsystems/attachment.zh.md
  77. 2 2
      docs/subsystems/feedback.i18n.yaml
  78. 3 3
      docs/subsystems/feedback.md
  79. 3 3
      docs/subsystems/feedback.zh.md
  80. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  81. 1 1
      docs/subsystems/llm-streaming.md
  82. 1 1
      docs/subsystems/llm-streaming.zh.md
  83. 1 2
      package.json
  84. 1 1
      packages/acp/acp/package.json
  85. 1 1
      packages/api/gateway/package.json
  86. 1 1
      packages/api/remotes/package.json
  87. 1 1
      packages/api/session-controller/package.json
  88. 1 1
      packages/api/settings-controller/package.json
  89. 1 1
      packages/api/workspace-controller/package.json
  90. 1 1
      packages/api/workspace-files/package.json
  91. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  92. 1 1
      packages/attachment/attachment-local/README.md
  93. 1 1
      packages/attachment/attachment-local/README.zh.md
  94. 1 1
      packages/attachment/attachment-local/package.json
  95. 6 6
      packages/attachment/attachment-local/src/index.ts
  96. 33 32
      packages/attachment/attachment-local/src/request-image.ts
  97. 1 1
      packages/attachment/attachment-local/tests/index.spec.ts
  98. 1 1
      packages/attachment/attachment-local/tests/request-image-verification.spec.ts
  99. 60 20
      packages/attachment/attachment-local/tests/request-image.spec.ts
  100. 2 2
      packages/attachment/attachment/README.i18n.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72
-2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e
+2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b
+2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -52,7 +52,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -52,7 +52,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
-2026-07-24-web-config-tree-boot-and-transport-layering.md: e7c0781e1504bce12a8b0d2197bd47b37d3e873a
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 9a0affa397151a3994c5731bebda4b45a4139b6c
+2026-07-24-web-config-tree-boot-and-transport-layering.md: c8a1db3cd7bd715d8912b22a0b41a9ef660c1316
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 41fcf65b06829199c203faa806bd17a310b68bcf

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 ## Decision
 
-**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
+**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. `auditStartupEntries` reports failed imports, reads failed fibers for original activation errors, and lists services that leave a fiber `PENDING`. The [startup policy](2026-09-09-consumer-owned-startup-strictness.md) makes optional failures warnings and required failures fatal. Reported rejection reasons stay marked through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
 
 **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
+**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。`auditStartupEntries` 报告 import 失败、读取失败 fiber 的原始激活错误,并列出让 fiber 停在 `PENDING` 的服务。[启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)将 optional failure 作为 warning,将 required failure 视为致命错误。已报告的 rejection 原因会保持标记至一个进程级检查点,使 `installFailLoud` 合并 Loader 的重复通知,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
 
 **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98
-2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d
+2026-08-03-per-session-agent-presets.md: c2c9f670df480662804e086fbf150c773d8f4fc8
+2026-08-03-per-session-agent-presets.zh.md: 9a4760be9e4a9d1d2de0a7f8d2d3bce755c04480

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

@@ -33,7 +33,7 @@ The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `
 
 **The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
 
-**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
+**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `auditStartupEntries` cannot see it. The mount reads its own rows through an `Include` subclass that publishes the tree and requires every enabled row to activate, independently of the [application startup policy](2026-09-09-consumer-owned-startup-strictness.md).
 
 **A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.
 

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

@@ -33,7 +33,7 @@ Status: implemented
 
 **有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
 
-**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
+**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`auditStartupEntries` 也看不到它。挂载过程通过一个会公开自身 tree 的 `Include` 子类读取各行,并要求每个启用行都激活,不受[应用启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)影响。
 
 **preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.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-consumer-owned-startup-strictness.md
+2026-09-09-consumer-owned-startup-strictness.md: 8e3d5f0141245ba2fa2c85faba607ac1be952e37
+2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md

@@ -0,0 +1,35 @@
+# Agent Note: Consumer-owned startup strictness
+
+Status: implemented
+
+English | [中文](2026-09-09-consumer-owned-startup-strictness.zh.md)
+
+## Problem
+
+Best-effort Loader reconciliation preserves usable plugins, but applications still need a minimum set of capabilities. An HTTP application without its listening server is not running, while an unavailable tool can be omitted without making the remaining application unusable. Cordis cannot infer this distinction from plugin implementation or dependency state.
+
+## Decision
+
+DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. The bootstrap Include is required by entry identity because a missing or invalid root configuration prevents application assembly. Other inactive entries produce one warning and leave successful siblings running.
+
+The required ids are `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution, application endpoints, and Web bootstrap/transport. Web needs its client module registry and authenticated connection even when the HTTP server can listen without them. Providers already required through injection need no separate entry: their absence leaves a listed consumer pending or failed.
+
+The audit treats a throwing `disabled` expression as an entry failure, not a disabled entry, because evaluation never established whether to skip it. The same optional/required policy applies to that failure.
+
+The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair.
+
+This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-08-03-per-session-agent-presets.md) own a separate strict subtree audit.
+
+## Alternatives considered
+
+- **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group.
+- **Declare required entries in each profile.** Rejected because the same application endpoints would be duplicated across profile data and custom profiles. A global list treats missing ids as irrelevant while keeping stable shipped ids authoritative.
+- **Make every startup failure optional.** Rejected because a process that cannot expose its selected application endpoint must report launch failure.
+
+## Consequences
+
+Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root.
+
+## Testing
+
+App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md

@@ -0,0 +1,35 @@
+# Agent Note:由 consumer 持有启动严格语义
+
+Status: implemented
+
+[English](2026-09-09-consumer-owned-startup-strictness.md) | 中文
+
+## 问题
+
+Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最小 capability。HTTP 应用没有 listening server 就不算运行,而一个 tool 不可用时可以仅省略该 tool,剩余应用仍然可用。Cordis 无法从 plugin 实现或依赖状态推断这一区别。
+
+## 决策
+
+DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。Bootstrap Include 按 entry 身份被视为 required,因为根配置缺失或无效会阻止应用组装。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。
+
+Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行、应用 endpoint,以及 Web 启动与传输。即使 HTTP server 不依赖它们也能监听,Web 仍需要客户端模块注册表和经过认证的连接。通过注入已成为必需项的 provider 不需要单列:它们缺失时,已列出的消费方会保持 pending 或失败。
+
+审计将 `disabled` 表达式抛出的异常视为 entry 失败,而不是 entry 已禁用,因为求值未能确定是否跳过它。该失败遵循相同的 optional/required 策略。
+
+该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。
+
+该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-08-03-per-session-agent-presets.zh.md)持有独立的严格子树审计。
+
+## 考虑过的替代方案
+
+- **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。
+- **在每个 profile 中声明 required entry。** 拒绝,因为相同应用 endpoint 会在 profile data 与 custom profile 中重复。全局 list 会忽略缺失 id,同时让稳定的随附 id 保持权威。
+- **把所有启动失败都视为 optional。** 拒绝,因为无法暴露所选应用 endpoint 的进程必须报告启动失败。
+
+## 后果
+
+稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。
+
+## 测试
+
+App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-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/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
-2026-07-31-fail-loud-releases-the-terminal.md: e5196121a850997b5eff045a26dd7c638776196c
-2026-07-31-fail-loud-releases-the-terminal.zh.md: 5cec9fe7d4ac75df1b5c88f2ea4cb0a665730719
+2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed
+2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md

@@ -15,7 +15,7 @@ $ 1;2;4cecho hello
 zsh: command not found: 4cecho
 ```
 
-The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
+The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The [Loader activation audit](../simplification/2026-09-09-nontransactional-loader.md) reports config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
 
 Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md

@@ -17,7 +17,7 @@ zsh: command not found: 4cecho
 
 Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
 
-在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
+在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。([Loader 激活检查](../simplification/2026-09-09-nontransactional-loader.zh.md) 让配置树失败经 `boot()` 报告,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
 
 `/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
-2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726
-2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a
+2026-09-10-deepseek-image-token-calculator-v41.md: 668738221ebe3e634be7e4fe9d07c9627e0ceb4a
+2026-09-10-deepseek-image-token-calculator-v41.zh.md: a483282c6da667c2b8e32accb091e065419d44fc

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

@@ -12,7 +12,7 @@ English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
 
 `image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
 
-The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers. This change left the 640,000 total-pixel projection the harness applied before pricing and the catalog model ids as they were; the [successor](2026-09-10-deepseek-v41-request-image-projection.md) later replaced that projection with the same grid, so omitting `imagePixelBudget` now projects onto the token grid while a positive integer or `low` keeps a total-pixel budget.
 
 ## Alternatives considered
 
@@ -24,4 +24,4 @@ The test vectors are re-pinned from the published calculator. The request-pricin
 
 ## Consequences
 
-An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.
+An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The request projection later moved onto the same grid ([successor](2026-09-10-deepseek-v41-request-image-projection.md)), so the 640,000-pixel budget that made a square request image cost 422 tokens no longer applies. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.

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

@@ -12,7 +12,7 @@ Status: implemented
 
 `image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
 
-测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字。本次改动保留了 harness 在定价前应用的 640,000 总像素投影和 catalog 模型 id;[后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)把该投影换成了同一套网格,现在省略 `imagePixelBudget` 走 token 网格,正整数或 `low` 仍走总像素预算。
 
 ## 备选方案
 
@@ -24,4 +24,4 @@ Status: implemented
 
 ## 后果
 
-800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。
+800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。请求投影后来改为同一套网格([后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)),让正方形请求图片计 422 token 的 640,000 像素预算已不再适用。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。

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

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

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md

@@ -0,0 +1,31 @@
+# Agent Note: DeepSeek request images on the published token grid
+
+Status: implemented
+
+English | [中文](2026-09-10-deepseek-v41-request-image-projection.zh.md)
+
+## Problem
+
+The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps a 1708×966 grid, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid.
+
+Two smaller gaps sat beside it. The request version had no per-side cap: the provider rejects any image over 4096 pixels per side once a request carries 15 or more images, while normalization admits an 8192-pixel long edge, so a many-image session could fail on one thin image. The 1 MiB encoded-byte target was sized for 640,000-pixel outputs and would push a 1302×1302 photograph down the JPEG quality ladder.
+
+## Decision
+
+The route chooses each request image's dimensions; the attachment provider only resizes and encodes to them. `ImageRequestPolicy` in `dsh-attachment` becomes `ImageRequestTarget`: a width, a height, and the byte target for one attachment. `readImageRequest` resizes by the source long edge alone without enlargement, so the encoder derives the short edge as the route predicted, and keys its cache by the attachment id, target dimensions, byte target, encoder settings, and the new `request-image-v6` transform version, so no earlier cache entry or upload mapping is reused. `dsh-attachment` keeps two provider-neutral geometry exports: `requestImageDimensions` for a total-pixel budget and `longEdgeDimensions` for an exact long edge with a rounded short edge.
+
+`llm-deepseek` owns the provider rule. `image-tokens.ts` keeps the verbatim `v41` solver and adds `deepSeekRequestImageDimensions`: the source itself when its patch-padded grid fits the cap, otherwise the source aspect ratio at the solved grid's long edge, so a 3840×2160 source is sent as 1708×961 and the provider pads it to its 1708×966 grid. `resolveRequestImageTarget` applies that solver when `imagePixelBudget` is omitted, `requestImageDimensions` for a positive integer or the 512×512 `low` preset, then a 4096-pixel per-side cap on every request image so the image count never changes a target, and the route's 2 MiB byte target. Pricing prices `deepSeekImageTokens` of the same target, so the estimator and the sent image come from one solver. The pi-ai route derives its targets from its unchanged 2048×2048 pixel budget. Small images are never enlarged because the provider scales up below 544×544 pixels itself.
+
+## Alternatives considered
+
+**Raise the pixel budget to 1302×1302.** A total-pixel budget is right only for squares: a 16:9 source would be sent at 1.69 million pixels when the grid keeps 1.65 million, and a 4:1 source when it keeps 1.59 million, while extreme ratios would lose detail the grid keeps. One rule that reproduces the provider removes the guesswork.
+
+**A `token-grid` projection kind on the attachment policy, with the solver in `dsh-attachment`.** This was built first: the policy became a closed union of `pixel-budget` and `token-grid`, the solver moved into `request-projection.ts`, and `deepSeekImageTokens` imported it back. It put one provider's layout formula and patch constants into the provider-neutral package under a generic-looking name, needed an `unscaled` flag so the store could tell "send the source" from "send the solved size", and would grow a new union member for every provider rule. Handing the store a finished target keeps the provider rule beside the provider's pricing and leaves the store with no projection vocabulary at all.
+
+**Send the solver's exact grid dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; preserving the source aspect ratio can change how many token cells the rounded short edge covers. A 1224×1429 source is sent as 1187×1386: the published calculator gives 959 tokens for the source and 992 for the sent dimensions. Request generation and pricing share the target dimensions, and pricing applies the published calculator to that target.
+
+**Keep the 1 MiB target.** The target is not a cap: an output over it is still sent at the smallest ladder quality. At 1302×1302 a JPEG photograph at quality 85 lands between 400 KB and 1.2 MB, so 2 MiB keeps most images at the top quality and lets more PNG screenshots pass through losslessly, while the inline base64 fallback still holds about seven such images under its 20 MiB bound.
+
+## Consequences
+
+A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator applies the published token rules to the sent target dimensions. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots cannot record the sent dimensions; the `llm-deepseek` adapter tests pin the resolved targets and the projected handle text against a mock server, and the local store tests resize real images to targets.

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: DeepSeek 请求图片按官方 token 网格投影
+
+Status: implemented
+
+[English](2026-09-10-deepseek-v41-request-image-projection.md) | 中文
+
+## 问题
+
+harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966 的网格,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。
+
+旁边还有两个较小的缺口。请求版本没有单边上限:请求包含 15 张及以上图片时,提供方拒绝任何单边超过 4096 像素的图片,而规范化允许 8192 像素长边,多图会话可能因一张细长图失败。1 MiB 编码字节目标是按 640,000 像素输出定的,会把 1302×1302 的照片压到 JPEG 质量阶梯的低档。
+
+## 决策
+
+请求图片的尺寸由路由决定,附件提供方只负责缩放和编码。`dsh-attachment` 里的 `ImageRequestPolicy` 改为 `ImageRequestTarget`,即一张附件的目标宽、高和字节目标。`readImageRequest` 只按源图长边缩放且不放大,编码器按路由预测的方式推出短边;缓存按附件 id、目标尺寸、字节目标、编码参数和新的 `request-image-v6` 变换版本取键,因此之前的缓存条目和上传映射都不会被复用。`dsh-attachment` 保留两个提供方无关的几何导出:按总像素预算的 `requestImageDimensions`,以及长边精确、短边四舍五入的 `longEdgeDimensions`。
+
+提供方规则归 `llm-deepseek`。`image-tokens.ts` 保留逐字移植的 `v41` 求解器,并新增 `deepSeekRequestImageDimensions`:补齐 patch 后的网格在上限内就发源图本身,否则按源图宽高比取求解网格的长边,于是 3840×2160 的源图以 1708×961 发送,提供方再把它补齐到 1708×966 的网格。`resolveRequestImageTarget` 在省略 `imagePixelBudget` 时用这个求解器,正整数或 512×512 的 `low` 预设用 `requestImageDimensions`,然后对每张请求图片加 4096 像素单边上限,使图片数量不会改变目标,最后带上路由的 2 MiB 字节目标。计价对同一个目标算 `deepSeekImageTokens`,预估器和发出的图片来自同一个求解器。pi-ai 路由从它不变的 2048×2048 像素预算推导目标。小图不放大,因为提供方自己会放大 544×544 像素以下的图片。
+
+## 备选方案
+
+**把像素预算提高到 1302×1302。** 总像素预算只对正方形正确:16:9 的源图会按 169 万像素发送而网格只保留 165 万,4:1 的源图网格只保留 159 万,极端比例又会丢掉网格本会保留的细节。一条复现提供方的规则消除了猜测。
+
+**在附件策略上加 `token-grid` 投影种类,求解器放进 `dsh-attachment`。** 最初就是这样做的:策略变成 `pixel-budget` 和 `token-grid` 的封闭联合,求解器搬进 `request-projection.ts`,`deepSeekImageTokens` 再从那里引回来。这把一家提供方的布局公式和 patch 常量放进了提供方无关的包,还起了个看似通用的名字;存储层需要一个 `unscaled` 标志来区分「发源图」和「发求解尺寸」;以后每多一家提供方规则,联合就要多长一个分支。把算好的目标交给存储层,提供方规则和它的计价放在一起,存储层不需要任何投影词汇。
+
+**用填充缩放发送求解器的精确网格尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;保持源图宽高比可能改变取整后短边覆盖的 token 格数。1224×1429 的源图以 1187×1386 发送,官方计算器对源图计 959 token,对发送尺寸计 992 token。请求生成和定价共享目标尺寸,定价按目标尺寸应用官方计算规则。
+
+**保留 1 MiB 目标。** 目标不是上限:超过它的输出仍会以阶梯最小质量发送。1302×1302 的 JPEG 照片在质量 85 时约 400 KB 到 1.2 MB,2 MiB 让多数图片停在最高质量,也让更多 PNG 截图无损直发,而内联 base64 回退在 20 MiB 上界内仍能容纳约七张这样的图片。
+
+## 后果
+
+正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器按发送目标尺寸应用官方 token 计算规则。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照记录不到发送尺寸;`llm-deepseek` 适配器测试对着 mock 服务器固定了解析出的目标和投影后的句柄文本,本地存储测试把真实图片缩放到目标尺寸。

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

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

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

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

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

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

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

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

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

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

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

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

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml

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

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

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

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

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

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.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/simplification/2026-09-09-nontransactional-loader.md
+2026-09-09-nontransactional-loader.md: 7ea4db0abf0fb65e443126dbca9fd822e47ce2d0
+2026-09-09-nontransactional-loader.zh.md: 5293efb58998f68a25e1143a2d043da32ef06bfb

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md

@@ -0,0 +1,35 @@
+# Agent Note: Keep Loader mutations non-transactional
+
+Status: implemented
+
+English | [中文](2026-09-09-nontransactional-loader.zh.md)
+
+## Problem
+
+Transactional config reload preserves an old plugin generation after a failed edit, but requires Loader to own candidate imports, lifecycle settlement, rollback, option identity, and Include serialization. These changes make the vendored implementation substantially different from its pinned sources. Application startup and profile patch watching also depend on that settlement implicitly.
+
+## Decision
+
+Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932), resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins.
+
+Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree.
+
+Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch.
+
+Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure.
+
+Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
+
+## Alternatives considered
+
+**Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback.
+
+**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger.
+
+**Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover.
+
+## Consequences
+
+A plugin activation failure can leave the new options and a failed fiber in place. Callers that require active plugins must audit after settlement; awaiting `Loader.create()` alone does not establish activation. Automatic plugin rollback requires a separate future decision with evidence that its recovery benefit warrants the additional lifecycle implementation.
+
+[Live-patch tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md) retain controlled event delivery and native watcher coverage, while asserting failure reporting without rollback. The [terminal-release policy](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) remains applicable to fatal errors and partial boot teardown. Web preset composition and a real CLI webhook-created model Session provide the application-level verification beyond hand-mounted plugins.

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 保持 Loader 更改非事务化
+
+Status: implemented
+
+[English](2026-09-09-nontransactional-loader.md) | 中文
+
+## 问题
+
+事务化配置重载会在编辑失败后保留旧插件代次,但要求 Loader 负责候选导入、生命周期结算、回滚、选项对象身份和 Include 串行化。这些更改使 vendor 实现与固定来源产生显著差异。应用启动和 profile patch 监视也隐式依赖该结算行为。
+
+## 决策
+
+撤销 [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932) 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。
+
+Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。
+
+应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。
+
+Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。
+
+保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
+
+## 考虑过的替代方案
+
+**保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。
+
+**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。
+
+**将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。
+
+## 后果
+
+插件激活失败可能保留新选项和失败的 fiber。要求插件处于激活状态的调用者必须在结算后检查;仅等待 `Loader.create()` 不能证明激活。自动插件回滚需要后续独立决策,并证明其恢复收益值得额外的生命周期实现。
+
+[实时 patch 测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md) 保留受控事件投递和原生监视覆盖,同时断言不回滚时的失败报告。[终端释放策略](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 仍适用于致命错误和部分启动拆卸。Web preset 组合与真实 CLI webhook 创建的模型 Session 提供手动挂载插件之外的应用级验证。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
-2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c
-2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6
+2026-09-09-user-patch-hmr-test-delivery.md: fadd191650411f07c3b8b1f35c94e73869fbc8e2
+2026-09-09-user-patch-hmr-test-delivery.zh.md: b108cf25766ff108d87c429e9f208c2d4cf7afd2

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md

@@ -1,4 +1,4 @@
-# Agent Note: User-patch transactions control filesystem event delivery
+# Agent Note: User-patch tests control filesystem event delivery
 
 Status: implemented
 
@@ -6,22 +6,22 @@ English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md)
 
 ## Problem
 
-The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own.
+The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the refresh assertions from exercising the parser, activation, and recovery behavior they own.
 
 ## Decision
 
-The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
+The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
 
-The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees.
+The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
 
 ## Alternatives considered
 
-**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
+**Native notifications for every refresh assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
 
 **Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted.
 
-**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures.
+**Mock HMR registration or Include.** Rejected because the test must retain real recomposition, report activation failures, and preserve the running configuration after parse failures.
 
 ## Consequences
 
-The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.
+The refresh sequence retains its semantic assertions and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 用户 patch 事务控制文件系统事件投递
+# Agent Note: 用户 patch 测试控制文件系统事件投递
 
 Status: implemented
 
@@ -6,22 +6,22 @@ Status: implemented
 
 ## 问题
 
-[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。
+[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止刷新断言执行其负责验证的解析、激活与回滚行为。
 
 ## 决策
 
-[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
+[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
 
-独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。
+独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
 
 ## 考虑过的替代方案
 
-**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
+**每个刷新断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
 
 **轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。
 
-**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言。
+**Mock HMR 注册或 Include。** 不采用,因为测试必须保留真实重组、报告激活失败,并在解析失败后保留运行中的配置。
 
 ## 影响
 
-事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。
+刷新序列保留其语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
-2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076
-2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87
+2026-09-10-hosted-image-test-assumptions.md: 5cf3b14503162d67d0fbb27743228cb1a8d7ffe2
+2026-09-10-hosted-image-test-assumptions.zh.md: db82566c11b50b9203d30f7be6aec1378512f94b

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

@@ -20,6 +20,8 @@ Terminal cases that drive a mocked PTY exit pin the containment they need (`inte
 
 Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
 
+The stray-output sealing test in `packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies.
+
 The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
 
 The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption.

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

@@ -20,6 +20,8 @@ Status: implemented
 
 `packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
 
+`packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。
+
 Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
 
 Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。

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

@@ -249,10 +249,9 @@ jobs:
       - name: Configure persistent pnpm store
         shell: pwsh
         # The store must share the ReFS workspace volume for the clone
-        # import method below; LOCALAPPDATA (C:) would cross volumes and
-        # break block clone. See 2026-08-30-windows-refs-store-block-clone-install.
+        # import method below. Runner workspaces can reside on different drives.
         run: |
-          $storeRoot = "F:\.pnpm-store"
+          $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
           echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
 
       - name: Install (immutable)

+ 1 - 1
apps/cli/package.json

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

+ 1 - 0
apps/cli/src/profile-boot.ts

@@ -368,6 +368,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
           await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
         }
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
+        await ctx.loader.await()
       }
       await watchUserPatches(ctx, {
         binName: NAME,

+ 10 - 11
apps/cli/tests/built-bin.e2e.ts

@@ -28,6 +28,7 @@ const SPAWN_TIMEOUT_MS = 60_000
 const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
 const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
 const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
+const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
 
 async function runBuiltBin(
   args: readonly string[] = [],
@@ -400,7 +401,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 3 + 30_000)
 
-  it('reports SDK startup failure when stdin reaches EOF first', async () => {
+  it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
     const patch = join(home, 'broken-sdk.cordis.yml')
     writeFileSync(patch, [
@@ -415,9 +416,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         DSH_TELEMETRY_DISABLED: '1',
         DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
       }, home)
-      expect(result.code).toBe(1)
+      expect(result.code).toBe(0)
       expect(result.stdout).toBe('')
-      expect(result.stderr).toContain('plugin tree failed to load')
+      expect(result.stderr).toContain('warning: 1 entry did not activate')
       expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
     } finally {
       rmSync(home, { recursive: true, force: true })
@@ -756,20 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
-  it('reports a patch-overlay boot failure without hanging', async () => {
-    // The HMR main watcher's initial scan once refreshed the include
-    // mid-initial-apply, deadlocking the failing apply's rollback against the
-    // refresh drain: dsh exited 13 with no diagnostic instead of settling
-    // ([vendor/README.md](../../../vendor/README.md)).
+  it('keeps serving when an optional patch-overlay plugin fails', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
-      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
+      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
         DSH_HOME: home,
+        DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
         DEEPSEEK_API_KEY: 'keyless-invalid-config',
         DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: `--import=${webReadyExitHook}`,
       })
-      expect(result.code).toBe(1)
-      expect(result.stdout).toBe('')
+      expect(result.code, result.stderr).toBe(0)
+      expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
       expect(result.stderr).toContain('llm-pi-ai')
     } finally {
       rmSync(home, { recursive: true, force: true })

+ 1 - 1
apps/cli/tests/fixtures/invalid-provider.cordis.yml

@@ -1,4 +1,4 @@
-# Invalid `--patch` overlay used to prove boot failures settle and exit.
+# Invalid optional provider override used to prove best-effort startup continues.
 
 - id: llm-pi-ai
   config:

+ 4 - 2
apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt

@@ -1,3 +1,5 @@
-headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure
-Error: startup activation snapshot failure
+dsh: warning: 1 entry did not activate
+activation-error (./activation-error.mjs): Error: startup activation snapshot failure
     at activation-error-fixture
+dsh: reasoning:
+Inspecting the task before the tool call.

+ 1 - 7
apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml

@@ -1,10 +1,4 @@
-# Activation-failure patch over the shipped headless profile.
-- id: headless-startup
-  disabled: true
-
-- id: headless-runner
-  disabled: true
-
+# Unrelated activation failure over the shipped headless profile.
 - insert:
     - id: activation-error
       name: ./activation-error.mjs

+ 16 - 8
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -383,19 +383,27 @@ describe('headless stream-json snapshots', () => {
     await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('prints the original Loader activation error through the assembled one-shot app', async () => {
+  it('warns about an unrelated activation error and completes the headless task', async () => {
     const result = await runLoaderSmoke({
-      label: 'headless startup activation error snapshot',
+      label: 'headless best-effort startup snapshot',
       tempDirPrefix: 'headless-snapshot-startup-error-',
-      binScript,
-      libBinScript: binScript,
+      binScript: dshBinScript,
       configPath: startupFailureConfigPath,
-      binArgs: [startupFailureConfigPath, 'unreachable task'],
+      binArgs: [
+        '--profile', 'headless',
+        '--patch', headlessOverlayPath,
+        '--patch', startupFailureConfigPath,
+        'Complete the task despite the unrelated startup failure.',
+      ],
       tsconfigPath,
-      expectedExitCode: 1,
+      env: {
+        DSH_PERMISSION_MODE: 'danger-full-access',
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
     })
-    expect(result.stdout).toBe('')
-    await expect(result.stderr.replace(startupFailurePluginUrl, './activation-error.mjs'))
+    expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
+    await expect(result.stderr.replaceAll(startupFailurePluginUrl, './activation-error.mjs'))
       .toMatchFileSnapshot(startupFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 

+ 12 - 4
apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts

@@ -6,24 +6,32 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l
 
 const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url)
 const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot))
+const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
 const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url))
 
-it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => {
+it('warns about a repeated MCP discovery cursor and completes the headless task', async () => {
   const { stdout, stderr } = await runLoaderSmoke({
     label: 'MCP discovery pagination cycle',
     tempDirPrefix: 'dsh-mcp-pagination-',
     binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)),
     libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)),
     configPath,
-    binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'],
+    binArgs: [
+      '--profile', 'headless',
+      '--patch', headlessOverlayPath,
+      '--patch', configPath,
+      'Complete the task without the failed MCP server.',
+    ],
     tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)),
-    expectedExitCode: 1,
     env: {
       DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)),
+      DSH_PERMISSION_MODE: 'danger-full-access',
       DSH_TELEMETRY_DISABLED: '1',
     },
   })
-  expect(stdout).toBe('')
+  expect(stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
+  expect(stderr).toContain('dsh: warning: 1 entry did not activate')
+  expect(stderr).toContain('mcp-pagination-cycle (@deepseek-ai/dsh-mcp-client)')
   expect(stderr).toContain('initial connection or tool synchronization failed')
   const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):'))
   await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath)

+ 8 - 4
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -32,6 +32,10 @@ const binScript = fileURLToPath(new URL('../../../../../../packages/test-support
 const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url))
 // The resumed-agent fixture in the shared config resumes exactly this id.
 const sessionId = SessionId('workspace-context-resume')
+const fixtureEnv = {
+  DSH_SNAPSHOT_FILE: replayFixture,
+  DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID: 'resumed-agent',
+}
 
 /** Persist one session with the given header version and events, returning its log path. */
 async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
@@ -84,7 +88,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Continue the migrated session.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       prepare: async (runCwd) => {
         sourcePath = await seedSession(join(runCwd, '.sessions'), runCwd, 0, closedTurn())
         source = await readFile(sourcePath)
@@ -124,7 +128,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
@@ -151,7 +155,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none')
@@ -197,7 +201,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [

+ 2 - 1
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -355,7 +355,8 @@ describe('Python SDK dsh profile keyless smoke', () => {
       expect(exitCode, stderr).toBe(1)
       expect(stdout).toBe('')
       expect(stderr).toContain('plugin tree failed to load')
-      expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
+      expect(stderr).toContain('required startup failure')
+      expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
       expect(stderr).toContain('sometimes')
     } finally {
       await rm(root, { recursive: true, force: true })

+ 350 - 0
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -0,0 +1,350 @@
+/** Built Web-profile acceptance for best-effort initial plugin activation. */
+
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Readable } from 'node:stream'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
+const frontendIndex = join(repoRoot, 'apps/web/dist/index.html')
+const builtArtifactsExist = existsSync(dshBin) && existsSync(frontendIndex)
+
+interface Fixture {
+  root: string
+  home: string
+  patch: string
+  events: string
+  stop: string
+}
+
+function createFixture(): Fixture {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-web-best-effort-'))
+  const home = join(root, 'home')
+  const events = join(root, 'events.log')
+  const stop = join(root, 'stop')
+  mkdirSync(home)
+  writeFileSync(events, '')
+  writeFileSync(join(root, 'good.mjs'), [
+    "import { appendFileSync, existsSync } from 'node:fs'",
+    'export function apply(ctx, config) {',
+    "  appendFileSync(config.events, 'good apply\\n')",
+    '  let stopping = false',
+    '  const watcher = setInterval(() => {',
+    '    if (stopping || !existsSync(config.stop)) return',
+    '    stopping = true',
+    "    process.emit('SIGTERM')",
+    '  }, 20)',
+    '  ctx.effect(() => () => {',
+    '    clearInterval(watcher)',
+    "    appendFileSync(config.events, 'good dispose\\n')",
+    '  })',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n')
+  writeFileSync(join(root, 'async-failure.mjs'), [
+    'export async function apply() {',
+    '  await Promise.resolve()',
+    '  throw new Error("web async apply failure")',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'pending.mjs'), [
+    "export const inject = ['webProbeMissingService']",
+    'export function apply() {}',
+    '',
+  ].join('\n'))
+  const patch = join(root, 'failures.patch.yml')
+  writeFileSync(patch, [
+    '- id: tool-todo',
+    '  disabled: false',
+    '  config: {}',
+    '- insert:',
+    '    - id: web-probe-good',
+    `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
+    '      config:',
+    `        events: ${JSON.stringify(events)}`,
+    `        stop: ${JSON.stringify(stop)}`,
+    '    - id: web-probe-import-failure',
+    `      name: ${pathToFileURL(join(root, 'missing.mjs')).href}`,
+    '    - id: web-probe-sync-failure',
+    `      name: ${pathToFileURL(join(root, 'sync-failure.mjs')).href}`,
+    '    - id: web-probe-async-failure',
+    `      name: ${pathToFileURL(join(root, 'async-failure.mjs')).href}`,
+    '    - id: web-probe-pending',
+    `      name: ${pathToFileURL(join(root, 'pending.mjs')).href}`,
+    '    - id: web-probe-disabled-failure',
+    `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
+    '      disabled: !!js "JSON.parse(\'invalid\')"',
+    '',
+  ].join('\n'))
+  return { root, home, patch, events, stop }
+}
+
+async function waitForStartup(
+  stdout: Readable | null,
+  stderr: Readable | null,
+  completion: PromiseLike<{ exitCode?: number }>,
+): Promise<{ url: string; stderr: string }> {
+  if (stdout === null || stderr === null) throw new Error('Web child pipes are unavailable')
+  stdout.setEncoding('utf8')
+  stderr.setEncoding('utf8')
+  let stdoutText = ''
+  let stderrText = ''
+  let url: string | undefined
+  const ready = Promise.withResolvers<{ url: string; stderr: string }>()
+  let settled = false
+  const finish = (): void => {
+    if (settled || url === undefined) return
+    if (!stderrText.includes('dsh: warning: 6 entries did not activate')) return
+    if (!stderrText.includes('web async apply failure')) return
+    if (!stderrText.includes('webProbeMissingService')) return
+    settled = true
+    clearTimeout(timer)
+    ready.resolve({ url, stderr: stderrText })
+  }
+  stdout.on('data', (chunk: string) => {
+    stdoutText += chunk
+    url ??= /dsh web: (http:\/\/[^\s]+)/u.exec(stdoutText)?.[1]
+    finish()
+  })
+  stderr.on('data', (chunk: string) => {
+    stderrText += chunk
+    finish()
+  })
+  const timer = setTimeout(() => {
+    if (settled) return
+    settled = true
+    ready.reject(new Error(`Web profile did not report ready and failed entries\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  }, 60_000)
+  void completion.then((result) => {
+    if (settled) return
+    settled = true
+    clearTimeout(timer)
+    ready.reject(new Error(`Web profile exited before readiness (code ${String(result.exitCode)})\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  })
+  return ready.promise
+}
+
+describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => {
+  it('serves the full Web app while unrelated entries fail to start', async () => {
+    const fixture = createFixture()
+    const child = execa(process.execPath, [
+      dshBin,
+      '--profile', 'web',
+      '--patch', fixture.patch,
+      '--no-open',
+      '--port', '0',
+    ], {
+      cwd: fixture.root,
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: 'keyless-web-best-effort-no-call',
+        DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+        DSH_HOME: fixture.home,
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_NO_WARNINGS: '1',
+      },
+      input: '',
+      reject: false,
+      timeout: 90_000,
+      killSignal: 'SIGKILL',
+    })
+
+    let result: Awaited<typeof child>
+    let events = ''
+    try {
+      const startup = await waitForStartup(child.stdout, child.stderr, child)
+      const auth = await fetch(startup.url, { redirect: 'manual' })
+      const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
+      if (cookie === undefined) throw new Error('Web authentication response did not set a cookie')
+      const page = await fetch(new URL('/', startup.url), { headers: { cookie } })
+      const html = await page.text()
+      expect(html).toContain('<div id="root"></div>')
+      expect(html).toContain('__DSH_BOOT__')
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
+      expect(startup.stderr).toContain('web-probe-import-failure')
+      expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
+      expect(startup.stderr).toContain('web sync apply failure')
+      expect(startup.stderr).toContain('web async apply failure')
+      expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)')
+      expect(startup.stderr).toContain('web-probe-disabled-failure')
+      expect(startup.stderr).toContain('disabled expression failed: SyntaxError')
+    } finally {
+      writeFileSync(fixture.stop, 'stop')
+      result = await child
+      events = readFileSync(fixture.events, 'utf8')
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+
+    expect(result.signal).toBeUndefined()
+    expect({
+      exitCode: result.exitCode,
+      timedOut: result.timedOut,
+      events,
+    }).toMatchInlineSnapshot(`
+      {
+        "events": "good apply
+      good dispose
+      ",
+        "exitCode": 0,
+        "timedOut": false,
+      }
+    `)
+  })
+
+  it.each([
+    ['modules', 'missing dependency'],
+    ['connection', 'missing dependency'],
+    ['modules', 'disabled expression'],
+    ['connection', 'disabled expression'],
+  ])('fails the full Web profile on required %s %s failure', async (id, failure) => {
+    const fixture = createFixture()
+    const patch = failure === 'disabled expression'
+      ? 'disabled: !!js "JSON.parse(\'invalid\')"'
+      : 'inject: [webProbeMissingRequiredService]'
+    const diagnostic = failure === 'disabled expression'
+      ? 'disabled expression failed: SyntaxError'
+      : 'pending (waiting for service: webProbeMissingRequiredService)'
+    writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n  ${patch}\n`)
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--patch', fixture.patch,
+        '--no-open',
+        '--port', '0',
+      ], {
+        cwd: fixture.root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-required-no-call',
+          DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+          DSH_HOME: fixture.home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
+      expect(result.exitCode).toBe(1)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`)
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
+    } finally {
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+  })
+
+  it('fails the full Web profile when its required HTTP server cannot bind', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
+    const home = join(root, 'home')
+    mkdirSync(home)
+    const blocker = createServer()
+    await new Promise<void>((resolve, reject) => {
+      const fail = (error: Error): void => { reject(error) }
+      blocker.once('error', fail)
+      blocker.listen(0, '127.0.0.1', () => {
+        blocker.off('error', fail)
+        resolve()
+      })
+    })
+    const address = blocker.address()
+    if (address === null || typeof address === 'string') {
+      throw new Error('port blocker did not bind a TCP address')
+    }
+
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--no-open',
+        '--port', String(address.port),
+      ], {
+        cwd: root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-required-bind-no-call',
+          DSH_AGENTS_HOME: join(root, '.agents'),
+          DSH_HOME: home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
+      expect(result.exitCode).toBe(1)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain('EADDRINUSE')
+    } finally {
+      await new Promise<void>((resolve, reject) => {
+        blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
+      })
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
+
+  it('fails and cleans up when detached work rejects after application startup', async () => {
+    const fixture = createFixture()
+    const plugin = join(fixture.root, 'detached.mjs')
+    writeFileSync(plugin, [
+      'export function apply(ctx) {',
+      '  ctx.effect(() => ctx.get("appReady").onReady(() => {',
+      '    void Promise.reject(new Error("detached Web failure"))',
+      '  }))',
+      '}',
+      '',
+    ].join('\n'))
+    writeFileSync(fixture.patch, readFileSync(fixture.patch, 'utf8') + [
+      '- insert:',
+      '    - id: detached-probe',
+      `      name: ${pathToFileURL(plugin).href}`,
+      '',
+    ].join('\n'))
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--patch', fixture.patch,
+        '--no-open',
+        '--port', '0',
+      ], {
+        cwd: fixture.root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-detached-no-call',
+          DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+          DSH_HOME: fixture.home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
+      expect(result.exitCode).toBe(1)
+      expect(result.stderr).toContain('fatal load failure: Error: detached Web failure')
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
+    } finally {
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+  })
+})

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

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

+ 1 - 1
apps/desktop/package.json

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

+ 1 - 1
apps/web/package.json

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

+ 1 - 1
apps/web/tests/built-boot.expected.e2e.ts

@@ -101,7 +101,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
   // Skip the resident fixture's three questions, then resolve its approval so
   // the ordinary composer bar (which owns ContextMeter) resumes.
   for (let index = 0; index < 3; index += 1) {
-    fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' }))
+    fireEvent.click(await screen.findByRole('button', { name: 'Skip' }))
   }
   fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
 

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

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

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

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

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

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

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

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

+ 1 - 1
apps/web/tests/question-composer.e2e.ts

@@ -345,7 +345,7 @@ describe('web e2e: resident question composer round trip', () => {
     expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
 
     // Settle the wait so teardown is not racing a pending question.
-    await composer.getByRole('button', { name: 'Skip this question' }).click()
+    await composer.getByRole('button', { name: 'Skip' }).click()
     expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
     await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
   }, 60_000)

+ 2 - 2
apps/web/tests/scaffold.ts

@@ -57,7 +57,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
-  assertEntriesLoaded,
+  auditStartupEntries,
   composeEntries,
   healProfilesModuleFallback,
   loadOverlayPatches,
@@ -711,7 +711,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       config: { path: pathToFileURL(rootConfig).href, patches },
     })
     await ctx.loader.await()
-    assertEntriesLoaded(ctx, 'web e2e scaffold')
+    await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== true) {
       await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
         op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 8d72cc52dad277a606a923c74723c34d9a2e0a4a
-config-catalog.zh.md: 1b70aabcba62de49619dd8a682a089449c314f9f
+config-catalog.md: ec65c5e41c2564676fd4445203954d69688703a0
+config-catalog.zh.md: 0237d9e01766e36db2106f6b36d038d0de6fa65e

+ 6 - 2
docs/config-catalog.md

@@ -1083,7 +1083,11 @@ export interface DeepSeekCatalogModel {
   maxTokens?: number
   /** Accepted request modalities; omission is text-only. */
   inputModalities?: ModelModality[]
-  /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+  /**
+   * Total-pixel budget replacing the published token-grid projection for one
+   * deterministic request preview, or the 512-by-512 `low` preset; omission
+   * projects onto the token grid.
+   */
   imagePixelBudget?: number | 'low'
   /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */
   imageMaxBytes?: number
@@ -1098,7 +1102,7 @@ export interface DeepSeekCatalogModel {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 6 - 2
docs/config-catalog.zh.md

@@ -1085,7 +1085,11 @@ export interface DeepSeekCatalogModel {
   maxTokens?: number
   /** Accepted request modalities; omission is text-only. */
   inputModalities?: ModelModality[]
-  /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+  /**
+   * Total-pixel budget replacing the published token-grid projection for one
+   * deterministic request preview, or the 512-by-512 `low` preset; omission
+   * projects onto the token grid.
+   */
   imagePixelBudget?: number | 'low'
   /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */
   imageMaxBytes?: number
@@ -1100,7 +1104,7 @@ export interface DeepSeekCatalogModel {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 2 - 2
docs/cordis-api/fiber.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md
-fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a
-fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b
+fiber.md: 3044301effde267bf580c3a5138481b10513f755
+fiber.zh.md: bee2f523f1fa2a2add2afc3a3fc322903f1ef6f0

+ 3 - 3
docs/cordis-api/fiber.md

@@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config.
  *
  * @param config — the new raw config; validated before anything restarts.
  * @param noSave — hint for persistence hooks not to write the change back.
- * @returns the update waterfall result; the default restart returns a promise.
- * @throws when validation, an update listener, or the restarted plugin fails.
+ * @returns nothing; the restart runs behind the `internal/update` waterfall.
+ * @throws {ValidationError} when the new config fails validation.
  */
 update(config: any, noSave = false)
 ```
@@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o
 - `config` — the new raw config; validated before anything restarts.
 - `noSave` — hint for persistence hooks not to write the change back.
 
-**Returns** the update waterfall result; the default restart returns a promise.
+**Returns** nothing; the restart runs behind the `internal/update` waterfall.
 
 [Source](../../vendor/cordis/src/fiber.ts#L736)
 

+ 3 - 3
docs/cordis-api/fiber.zh.md

@@ -258,8 +258,8 @@ dispose 此插件,并立即使用其当前配置重新加载。
  *
  * @param config — the new raw config; validated before anything restarts.
  * @param noSave — hint for persistence hooks not to write the change back.
- * @returns the update waterfall result; the default restart returns a promise.
- * @throws when validation, an update listener, or the restarted plugin fails.
+ * @returns nothing; the restart runs behind the `internal/update` waterfall.
+ * @throws {ValidationError} when the new config fails validation.
  */
 update(config: any, noSave = false)
 ```
@@ -271,7 +271,7 @@ update(config: any, noSave = false)
 - `config`:新的原始配置;在任何内容重新启动前进行校验。
 - `noSave`:提示持久化钩子不要写回此变更。
 
-**返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。
+**返回**无返回值;重启由 `internal/update` waterfall 执行。
 
 [源码](../../vendor/cordis/src/fiber.ts#L736)
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 9c918756389c71651de5a838ada0ad366379aaa6
-event-producer-consumer.zh.md: c2fdb42032ecf95d084635347fdccedf33a4a643
+event-producer-consumer.md: 239b93480a70e6b4d2107f7bdd177019d501fa5e
+event-producer-consumer.zh.md: 94ad41bfedcdbf4c63af5f9f346213fb0973cf34

+ 1 - 0
docs/event-producer-consumer.md

@@ -84,5 +84,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 1 - 0
docs/event-producer-consumer.zh.md

@@ -86,5 +86,6 @@
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 2 - 2
docs/rescope.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/rescope.md
-rescope.md: 676dee2507a974beb20a4c0e8059b6a74aa9c0c9
-rescope.zh.md: 1683111354b1ff7c8771a87b7b03bcbeb0ac0761
+rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9
+rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573

+ 1 - 1
docs/rescope.md

@@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@
 ## What the rename does not touch
 
 - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version.
-- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces.
+- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing.
 - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name.
 - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`.
 - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`.

+ 1 - 1
docs/rescope.zh.md

@@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R
 ## 改名不碰什么
 
 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。
-- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace。
+- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围。
 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。
 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。
 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。

+ 2 - 2
docs/subsystems/attachment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
-attachment.md: 53b3e51e5b1c87625256178287061386342bc055
-attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1
+attachment.md: db1c1cfa15f5d0815236f96b6472cbbd864decbd
+attachment.zh.md: 82379de5049012c0912734dc94257d6b8f354ad1

+ 17 - 7
docs/subsystems/attachment.md

@@ -126,15 +126,25 @@ interface StoredImageAttachment {
 ```
 
 ```ts type-equiv
-/** Deterministic request-image policy selected by one exact model route. */
-interface ImageRequestPolicy {
-  /** Maximum width multiplied by height after aspect-preserving projection. */
-  maxPixels: number
+/** Deterministic request-image target selected by one exact model route for one attachment. */
+interface ImageRequestTarget {
+  /** Target width in pixels; a target above the source keeps the source width. */
+  width: number
+  /** Target height in pixels; a target above the source keeps the source height. */
+  height: number
   /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
   maxBytes: number
 }
 ```
 
+```ts type-equiv
+/** Integer width and height of one projected image. */
+interface ProjectedDimensions {
+  width: number
+  height: number
+}
+```
+
 ```ts type-equiv
 /** Cached request version derived from one provider-independent normalized attachment. */
 interface RequestImageAttachment {
@@ -157,7 +167,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
+`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -280,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined
 /**
  * Generate or read one deterministic model-request version from the stored normalized image.
  * @param ref - durable provider-independent normalized attachment reference.
- * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
+ * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
  * @param signal - optional cancellation.
  * @returns request bytes and the cache/upload identity covering every transform input.
  */
-readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>
+readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise<RequestImageAttachment>
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts)

+ 17 - 7
docs/subsystems/attachment.zh.md

@@ -126,15 +126,25 @@ interface StoredImageAttachment {
 ```
 
 ```ts type-equiv
-/** Deterministic request-image policy selected by one exact model route. */
-interface ImageRequestPolicy {
-  /** Maximum width multiplied by height after aspect-preserving projection. */
-  maxPixels: number
+/** Deterministic request-image target selected by one exact model route for one attachment. */
+interface ImageRequestTarget {
+  /** Target width in pixels; a target above the source keeps the source width. */
+  width: number
+  /** Target height in pixels; a target above the source keeps the source height. */
+  height: number
   /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
   maxBytes: number
 }
 ```
 
+```ts type-equiv
+/** Integer width and height of one projected image. */
+interface ProjectedDimensions {
+  width: number
+  height: number
+}
+```
+
 ```ts type-equiv
 /** Cached request version derived from one provider-independent normalized attachment. */
 interface RequestImageAttachment {
@@ -157,7 +167,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
+`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -280,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined
 /**
  * Generate or read one deterministic model-request version from the stored normalized image.
  * @param ref - durable provider-independent normalized attachment reference.
- * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
+ * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
  * @param signal - optional cancellation.
  * @returns request bytes and the cache/upload identity covering every transform input.
  */
-readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>
+readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise<RequestImageAttachment>
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts)

+ 2 - 2
docs/subsystems/feedback.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
-feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5
-feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99
+feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03
+feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7

+ 3 - 3
docs/subsystems/feedback.md

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

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

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

+ 2 - 2
docs/subsystems/llm-streaming.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 77d3313b3c1a025f756e984453bd041abd7b0534
-llm-streaming.zh.md: cac7897ab412f45bd5b5ab20c6460644f18f4ff2
+llm-streaming.md: 28492b46435e3288609129b0dfc5cb40dd5f7296
+llm-streaming.zh.md: fe02e2ebe5b558162e849e26ff05b8d633c07ba0

+ 1 - 1
docs/subsystems/llm-streaming.md

@@ -249,7 +249,7 @@ interface LlmFailure {
 
 ## Request-image pricing
 
-An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
+An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model request target, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/llm-streaming.zh.md

@@ -251,7 +251,7 @@ interface LlmFailure {
 
 ## 请求图片定价
 
-提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
+提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的请求目标、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
 
 ```ts type-equiv
 /**

+ 1 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "license": "MIT",
   "private": true,
   "type": "module",
@@ -134,7 +134,6 @@
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts",
-    "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",
     "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 2 - 2
packages/attachment/attachment-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
-README.md: c7d5e58cca77bfb182964a22139d6925bdc64501
-README.zh.md: fdfcf02f20cece5f015e438283b6cd4e426f66d9
+README.md: ea15a8700bf07fc640375b0d1ea4af18c7ff2199
+README.zh.md: f9b24dfd8e3b67a144bf724616d203cbf6e4dd63

+ 1 - 1
packages/attachment/attachment-local/README.md

@@ -85,7 +85,7 @@ Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`; eq
 
 Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion.
 
-Request versions live below `<DSH_HOME>/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
+Request versions live below `<DSH_HOME>/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
 
 Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
 

+ 1 - 1
packages/attachment/attachment-local/README.zh.md

@@ -85,7 +85,7 @@ kind: "package-reference"
 
 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。
 
-请求版本位于由 `dshCachePath` 解析的 `<DSH_HOME>/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
+请求版本位于由 `dshCachePath` 解析的 `<DSH_HOME>/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
 
 通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
 

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

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

+ 6 - 6
packages/attachment/attachment-local/src/index.ts

@@ -8,7 +8,7 @@ import type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveFileAttachment,
   SaveFileStreamAttachment,
@@ -247,20 +247,20 @@ export class LocalAttachmentStore extends AttachmentStore {
 
   override async readImageRequest(
     ref: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
-    return this.requestVersion(ref, policy, undefined, signal)
+    return this.requestVersion(ref, target, undefined, signal)
   }
 
   private requestVersion(
     ref: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     stored: StoredImageAttachment | undefined,
     signal: AbortSignal | undefined,
   ): Promise<RequestImageAttachment> {
     signal?.throwIfAborted()
-    const variantId = requestImageVariantId(ref, policy)
+    const variantId = requestImageVariantId(ref, target)
     const key = String(variantId)
     let operation = this.requestInflight.get(key)
     if (operation?.controller.signal.aborted) {
@@ -272,7 +272,7 @@ export class LocalAttachmentStore extends AttachmentStore {
         const request = await readRequestImageFile(
           this.cacheRoot,
           stored ?? await this.readImage(ref, sharedSignal),
-          policy,
+          target,
           sharedSignal,
         )
         return request

+ 33 - 32
packages/attachment/attachment-local/src/request-image.ts

@@ -4,11 +4,11 @@ import { createHash, randomUUID } from 'node:crypto'
 import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import sharp, { type Sharp } from 'sharp'
-import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
+import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type {
   ImageMediaType,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   StoredImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
@@ -22,7 +22,7 @@ import {
 import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts'
 
 /** Transform version included in every cache and upload-index identity. */
-export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5'
+export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6'
 
 interface EncodedRequestImage {
   data: Uint8Array
@@ -46,17 +46,19 @@ function checkedInteger(value: number, name: string): number {
   return value
 }
 
-function validatePolicy(policy: ImageRequestPolicy): void {
-  checkedInteger(policy.maxPixels, 'Image request maxPixels')
-  checkedInteger(policy.maxBytes, 'Image request maxBytes')
+function validateTarget(target: ImageRequestTarget): void {
+  checkedInteger(target.width, 'Image request width')
+  checkedInteger(target.height, 'Image request height')
+  checkedInteger(target.maxBytes, 'Image request maxBytes')
 }
 
-function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string {
+function descriptor(attachment: ImageAttachmentRef, target: ImageRequestTarget): string {
   return JSON.stringify({
     transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION,
     attachmentId: attachment.attachmentId,
-    routePixelBudget: policy.maxPixels,
-    encodedByteBudget: policy.maxBytes,
+    targetWidth: target.width,
+    targetHeight: target.height,
+    encodedByteBudget: target.maxBytes,
     encoding: {
       webpQualities: IMAGE_ENCODING_QUALITIES,
       webpEffort: WEBP_ENCODING_EFFORT,
@@ -68,21 +70,23 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy):
 }
 
 /**
- * Complete deterministic identity for one attachment and route-owned request policy.
+ * Complete deterministic identity for one attachment and route-chosen request target.
  * @param attachment - provider-independent durable normalized attachment reference.
- * @param policy - route-owned pixel and byte policy.
+ * @param target - route-chosen dimensions and byte target.
  * @returns branded digest over every request transform input.
  */
 export function requestImageVariantId(
   attachment: ImageAttachmentRef,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
 ): ReturnType<typeof ImageVariantId> {
-  return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`)
+  return ImageVariantId(`sha256:${digest(descriptor(attachment, target))}`)
 }
 
-function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp {
+/** Resize by the source long edge only, so the encoder derives the short edge as the route predicts. */
+function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget): Sharp {
+  const byWidth = attachment.ref.width >= attachment.ref.height
   return sourcePipeline(attachment)
-    .resize({ width, height, fit: 'inside', withoutEnlargement: true })
+    .resize({ ...byWidth ? { width: target.width } : { height: target.height }, withoutEnlargement: true })
 }
 
 function sourcePipeline(attachment: StoredImageAttachment): Sharp {
@@ -91,13 +95,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp {
 
 async function createRequestImage(
   attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   hasAlpha: boolean,
 ): Promise<EncodedRequestImage> {
-  const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
-  if (dimensions.width === attachment.ref.width
-    && dimensions.height === attachment.ref.height
-    && attachment.data.byteLength <= policy.maxBytes) {
+  if (target.width >= attachment.ref.width
+    && target.height >= attachment.ref.height
+    && attachment.data.byteLength <= target.maxBytes) {
     return {
       data: attachment.data,
       mediaType: attachment.ref.mediaType,
@@ -106,8 +109,8 @@ async function createRequestImage(
     }
   }
   const encodedVersion = await encodeFirstWithinLimit(
-    encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha),
-    policy.maxBytes,
+    encodingLadder(pipeline(attachment, target), hasAlpha),
+    target.maxBytes,
   )
   return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion
 }
@@ -118,17 +121,15 @@ function cachePath(root: string, hash: string): string {
 
 async function readCached(
   path: string,
-  attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   expectedAlpha: boolean,
   signal?: AbortSignal,
 ): Promise<VerifiedRequestImage | undefined> {
   try {
     const data = new Uint8Array(await readFile(path, { signal }))
     const detected = await probeImage(data)
-    const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
     if (detected.depth !== 'uchar' || detected.space !== 'srgb'
-      || detected.width > maximum.width || detected.height > maximum.height
+      || detected.width > target.width || detected.height > target.height
       || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined
     return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha }
   } catch (error: unknown) {
@@ -169,24 +170,24 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
  * Generate or reuse one request image below the local attachment cache root.
  * @param root - absolute attachment cache root; variants use its `request-images` child.
  * @param attachment - verified normalized attachment bytes and reference.
- * @param policy - exact route request-image policy.
+ * @param target - exact route-chosen dimensions and byte target; a target above the source keeps the source size.
  * @param signal - optional cancellation for cache I/O and image transformation.
  * @returns verified request bytes and deterministic variant identity.
  */
 export async function readRequestImageFile(
   root: string,
   attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   signal?: AbortSignal,
 ): Promise<RequestImageAttachment> {
   signal?.throwIfAborted()
-  validatePolicy(policy)
+  validateTarget(target)
   const source = await probeImage(attachment.data)
-  const variantId = requestImageVariantId(attachment.ref, policy)
+  const variantId = requestImageVariantId(attachment.ref, target)
   const hash = String(variantId).slice('sha256:'.length)
   const path = cachePath(root, hash)
-  const cached = await readCached(path, attachment, policy, source.hasAlpha, signal)
-  const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha)
+  const cached = await readCached(path, target, source.hasAlpha, signal)
+  const created = cached ?? await createRequestImage(attachment, target, source.hasAlpha)
   const version = cached ?? (created.data === attachment.data
     ? { ...created, hasAlpha: source.hasAlpha }
     : await verifyRequestImage(created, source.hasAlpha))

+ 1 - 1
packages/attachment/attachment-local/tests/index.spec.ts

@@ -85,7 +85,7 @@ describe('local attachment service', () => {
         String(ref.attachmentId).slice('sha256:'.length),
       ))
       await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data))
-      const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 })
+      const request = await service.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1024 })
       expect(request).not.toHaveProperty('access')
 
       const fileData = Uint8Array.of(0, 1, 2, 255)

+ 1 - 1
packages/attachment/attachment-local/tests/request-image-verification.spec.ts

@@ -38,7 +38,7 @@ describe('request image verification', () => {
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
     control.mismatch = true
 
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }))
+    await expect(attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }))
       .rejects.toMatchObject({
         code: 'ATTACHMENT_WRITE_FAILED',
         message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.',

+ 60 - 20
packages/attachment/attachment-local/tests/request-image.spec.ts

@@ -58,7 +58,7 @@ describe('local request-image cache', () => {
       const stored = await attachments.readImage(attachment)
       const fileData = Uint8Array.of(0, 1, 2, 255)
       const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' })
-      const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
+      const policy = { width: 22, height: 11, maxBytes: 4_096 }
       const initial = await attachments.readImageRequest(attachment, policy)
       const hash = String(initial.variantId).slice('sha256:'.length)
       const cacheRoot = join(dshHome, 'cache')
@@ -86,7 +86,7 @@ describe('local request-image cache', () => {
     const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
     const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })
     const firstStored = await attachments.readImage(first)
-    const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 }
+    const policy = { width: 8, height: 8, maxBytes: 1024 * 1024 }
 
     const request = await attachments.readImageRequest(first, policy)
     const batch = await Promise.all([first, second].map(
@@ -97,21 +97,61 @@ describe('local request-image cache', () => {
     expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId])
   })
 
-  it('rejects invalid request policies', async () => {
+  it('rejects invalid request targets', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
 
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 }))
-      .rejects.toThrow('Image request maxPixels must be a positive integer')
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 }))
+    await expect(attachments.readImageRequest(attachment, { width: 0, height: 4, maxBytes: 100 }))
+      .rejects.toThrow('Image request width must be a positive integer')
+    await expect(attachments.readImageRequest(attachment, { width: 8, height: 1.5, maxBytes: 100 }))
+      .rejects.toThrow('Image request height must be a positive integer')
+    await expect(attachments.readImageRequest(attachment, { width: 8, height: 4, maxBytes: 0 }))
       .rejects.toThrow('Image request maxBytes must be a positive integer')
   })
 
+  it('resizes by the long edge to the exact target and keys the cache by target', async () => {
+    const attachments = await store()
+    const maxBytes = 2 * 1024 * 1024
+    const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' })
+    const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' })
+    const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' })
+    const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' })
+    const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' })
+
+    const squareRequest = await attachments.readImageRequest(square, { width: 1302, height: 1302, maxBytes })
+    const smallRequest = await attachments.readImageRequest(small, { width: 800, height: 800, maxBytes })
+    const thinRequest = await attachments.readImageRequest(thin, { width: 4096, height: 20, maxBytes })
+    const wideRequest = await attachments.readImageRequest(wide, { width: 1708, height: 961, maxBytes })
+    const tallRequest = await attachments.readImageRequest(tall, { width: 961, height: 1708, maxBytes })
+    const smaller = await attachments.readImageRequest(square, { width: 1024, height: 1024, maxBytes })
+    const enlarged = await attachments.readImageRequest(thin, { width: 9000, height: 45, maxBytes })
+
+    expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' })
+    expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' })
+    expect(smallRequest.data).toEqual((await attachments.readImage(small)).data)
+    expect(thinRequest).toMatchObject({ width: 4096, height: 20 })
+    expect(wideRequest).toMatchObject({ width: 1708, height: 961 })
+    expect(tallRequest).toMatchObject({ width: 961, height: 1708 })
+    expect(enlarged).toMatchObject({ width: 8000, height: 40 })
+    expect(smaller.variantId).not.toBe(squareRequest.variantId)
+    expect(enlarged.variantId).not.toBe(thinRequest.variantId)
+  })
+
+  it('encodes the rounded short edge at the route target', async () => {
+    const attachments = await store()
+    const attachment = await attachments.saveImage({ data: await image(1224, 1429), mediaType: 'image/png' })
+    const request = await attachments.readImageRequest(attachment, {
+      width: 1187, height: 1386, maxBytes: 2 * 1024 * 1024,
+    })
+    expect(request).toMatchObject({ width: 1187, height: 1386 })
+    await expect(sharp(request.data).metadata()).resolves.toMatchObject({ width: 1187, height: 1386 })
+  })
+
   it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })
+    const request = await attachments.readImageRequest(attachment, { width: 1, height: 1, maxBytes: 1 })
 
     expect(request.mediaType).toBe('image/jpeg')
     expect(request.bytes).toBeGreaterThan(1)
@@ -122,7 +162,7 @@ describe('local request-image cache', () => {
     const dshHome = await home()
     const attachments = new LocalAttachmentStore(new Context(), { dshHome })
     const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
-    const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
+    const policy = { width: 22, height: 11, maxBytes: 4_096 }
     const initial = await attachments.readImageRequest(attachment, policy)
     const hash = String(initial.variantId).slice('sha256:'.length)
     const path = join(dshHome, 'cache', 'attachments', 'request-images', hash.slice(0, 2), hash)
@@ -171,10 +211,10 @@ describe('local request-image cache', () => {
       data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png',
     })
 
-    const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 })
+    const squareRequest = await attachments.readImageRequest(square, { width: 800, height: 800, maxBytes: 1024 * 1024 })
+    const wideRequest = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
+    const repeated = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
+    const low = await attachments.readImageRequest(wide, { width: 724, height: 362, maxBytes: 1024 * 1024 })
 
     expect(squareRequest).toMatchObject({ width: 800, height: 800 })
     expect(wideRequest).toMatchObject({ width: 1130, height: 565 })
@@ -214,8 +254,8 @@ describe('local request-image cache', () => {
     const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })
     const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })
 
-    const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 })
-    const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 })
+    const photoRequest = await attachments.readImageRequest(photo, { width: 128, height: 128, maxBytes: 1024 * 1024 })
+    const alphaRequest = await attachments.readImageRequest(alpha, { width: 128, height: 128, maxBytes: 4_096 })
 
     expect(photoRequest.mediaType).toBe('image/jpeg')
     expect(alphaRequest.mediaType).toBe('image/webp')
@@ -231,7 +271,7 @@ describe('local request-image cache', () => {
     }).toColourspace('rgb16').png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
 
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
     expect(request.width * request.height).toBeLessThanOrEqual(16 * 16)
@@ -245,7 +285,7 @@ describe('local request-image cache', () => {
     const source = await complexOpaqueAlphaImage(64, 32)
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
 
     expect(request.mediaType).toBe('image/webp')
     await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false })
@@ -267,7 +307,7 @@ describe('local request-image cache', () => {
     }).png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 800, height: 800, maxBytes: 1024 * 1024 })
 
     expect(request).toMatchObject({ width: 800, height: 800 })
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
@@ -280,7 +320,7 @@ describe('local request-image cache', () => {
     })
     const run = vi.spyOn(CompressionLimiter.prototype, 'run')
     const controller = new AbortController()
-    const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
+    const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
 
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     const completed = attachments.readImageRequest(attachment, policy)
@@ -310,7 +350,7 @@ describe('local request-image cache', () => {
     const controller = new AbortController()
     const request = attachments.readImageRequest(
       attachment,
-      { maxPixels: 640_000, maxBytes: 1024 * 1024 },
+      { width: 1130, height: 565, maxBytes: 1024 * 1024 },
       controller.signal,
     )
     await vi.waitFor(() => {
@@ -343,7 +383,7 @@ describe('local request-image cache', () => {
       return actualRead(ref, signal)
     })
     const controller = new AbortController()
-    const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
+    const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     await vi.waitFor(() => {
       expect(calls).toBe(1)

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
-README.md: 35fde71833fa13b26662244cfa68cec17b90b663
-README.zh.md: b0e98d1e4a0c35082a8eef23f69659f47916d3f7
+README.md: d2a667e2d12deadc5e82a83cca6565f0b5a6aae0
+README.zh.md: b5598eb8ccca39d8b5be346c585fd84d3dd38468

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است