Bladeren bron

Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

creatixchu 1 week geleden
bovenliggende
commit
3c632ebf66
100 gewijzigde bestanden met toevoegingen van 1781 en 259 verwijderingen
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml
  5. 4 4
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
  6. 4 4
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  11. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  12. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  14. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  15. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml
  17. 39 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
  18. 39 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md
  19. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml
  20. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
  21. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md
  22. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  23. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  24. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  25. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml
  26. 31 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md
  27. 31 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md
  28. 6 0
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  29. 39 0
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  30. 39 0
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  31. 6 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml
  32. 29 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
  33. 29 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md
  34. 6 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml
  35. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md
  36. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md
  37. 2 2
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml
  38. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
  39. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md
  40. 2 3
      .github/workflows/ci-master.yml
  41. 2 2
      apps/cli/README.i18n.yaml
  42. 4 2
      apps/cli/README.md
  43. 4 2
      apps/cli/README.zh.md
  44. 1 0
      apps/cli/src/profile-boot.ts
  45. 10 11
      apps/cli/tests/built-bin.e2e.ts
  46. 1 1
      apps/cli/tests/fixtures/invalid-provider.cordis.yml
  47. 4 2
      apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt
  48. 1 7
      apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml
  49. 16 8
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  50. 12 4
      apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts
  51. 8 4
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  52. 2 1
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  53. 350 0
      apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
  54. 404 0
      apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
  55. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  56. 75 0
      apps/web/tests/deepseek-messages-chat.e2e.ts
  57. 103 0
      apps/web/tests/deepseek-messages-settings.e2e.ts
  58. 87 0
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  59. 9 0
      apps/web/tests/expected/deepseek-messages-settings/picker.expected.md
  60. 1 0
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  61. 1 0
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  62. 8 15
      apps/web/tests/lifecycle-chrome.e2e.ts
  63. 1 1
      apps/web/tests/question-composer.e2e.ts
  64. 30 20
      apps/web/tests/scaffold.ts
  65. 2 0
      apps/web/tests/shipped-composition.e2e.ts
  66. 2 0
      apps/web/tsconfig.json
  67. 2 2
      docs/config-catalog.i18n.yaml
  68. 12 3
      docs/config-catalog.md
  69. 14 5
      docs/config-catalog.zh.md
  70. 2 2
      docs/cordis-api/fiber.i18n.yaml
  71. 3 3
      docs/cordis-api/fiber.md
  72. 3 3
      docs/cordis-api/fiber.zh.md
  73. 2 2
      docs/event-producer-consumer.i18n.yaml
  74. 1 0
      docs/event-producer-consumer.md
  75. 1 0
      docs/event-producer-consumer.zh.md
  76. 2 2
      docs/rescope.i18n.yaml
  77. 1 1
      docs/rescope.md
  78. 1 1
      docs/rescope.zh.md
  79. 2 2
      docs/subsystems/attachment.i18n.yaml
  80. 17 7
      docs/subsystems/attachment.md
  81. 17 7
      docs/subsystems/attachment.zh.md
  82. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  83. 1 1
      docs/subsystems/llm-streaming.md
  84. 1 1
      docs/subsystems/llm-streaming.zh.md
  85. 2 2
      docs/subsystems/system-prompt.i18n.yaml
  86. 3 1
      docs/subsystems/system-prompt.md
  87. 3 1
      docs/subsystems/system-prompt.zh.md
  88. 0 1
      package.json
  89. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  90. 1 1
      packages/attachment/attachment-local/README.md
  91. 1 1
      packages/attachment/attachment-local/README.zh.md
  92. 6 6
      packages/attachment/attachment-local/src/index.ts
  93. 33 32
      packages/attachment/attachment-local/src/request-image.ts
  94. 1 1
      packages/attachment/attachment-local/tests/index.spec.ts
  95. 1 1
      packages/attachment/attachment-local/tests/request-image-verification.spec.ts
  96. 60 20
      packages/attachment/attachment-local/tests/request-image.spec.ts
  97. 2 2
      packages/attachment/attachment/README.i18n.yaml
  98. 2 2
      packages/attachment/attachment/README.md
  99. 2 2
      packages/attachment/attachment/README.zh.md
  100. 7 6
      packages/attachment/attachment/src/index.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md
-2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57
-2026-06-13-twin-llm-adapters.zh.md: 36996750a16cc95393d727cffee3bcc53573eaf2
+2026-06-13-twin-llm-adapters.md: fe8b0b55e0e027e29eb0920e64a1760d0dc35aee
+2026-06-13-twin-llm-adapters.zh.md: 4248b5afeb9f3fd4503914e53203c674e3a80dbf

+ 2 - 0
.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md

@@ -25,3 +25,5 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for
 ## Consequences
 
 The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the direct-fetch adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note.
+
+The [Messages adapter](../feature/2026-09-07-deepseek-messages-adapter.md) adds an Anthropic-protocol implementation inside `llm-deepseek`; it preserves the same stream conventions.

+ 2 - 0
.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md

@@ -25,3 +25,5 @@ Status: implemented
 ## 后果
 
 孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;直接 fetch 适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。
+
+[Messages 适配器](../feature/2026-09-07-deepseek-messages-adapter.zh.md) 在 `llm-deepseek` 内增加 Anthropic 协议实现,并遵守相同的流约定。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.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-05-prompt-variables-and-tool-guidance-ownership.md
-2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 35bb7c6fabc85ae6f93bdbb67e13910eea627ca3
-2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 53d69cf45c02f6534334561b626d2c2ae6087c05
+2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f5542cb09930e62fd1e26960166fbdd6d3f6745
+2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 348ae36cf6549d412adab1d3cdb5cc03f0badd01

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md

@@ -26,7 +26,7 @@ The assembled system prompt had four defects, all of one family: facts the harne
 
 ### Prompt variables
 
-Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique.
+Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set `interpolate: false` to preserve generated documents literally; `tools:sdk` does so because tool descriptions and schemas may document their own `{{…}}` syntax.
 
 `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own.
 
@@ -47,7 +47,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
 - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.)
 - **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model.
 - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this decision cures.
-- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review.
+- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. Leaving only unknown names unchanged would still substitute registered names inside tool documentation.
 - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words.
 - **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md).
 
@@ -60,7 +60,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
 
 - The tui-agent prompt renders identity, persona with the interpolated model, then fs/shell/web guidance through one assembly path.
 - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes.
-- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
+- In interpolated sections, unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
 - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request.
 
 ## Consequences
@@ -69,4 +69,4 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
 - `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted.
 - While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted.
 - Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud.
-- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it.
+- Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded `ptc-turn` scenario preserves tool-template examples in the model-visible prompt.

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md

@@ -26,7 +26,7 @@ Status: implemented
 
 ### 提示词变量
 
-插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。
+插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 `interpolate: false` 来原样保留生成的文档;`tools:sdk` 使用此设置,因为工具描述和 schema 可能会介绍自身的 `{{…}}` 语法。
 
 `dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。
 
@@ -47,7 +47,7 @@ Status: implemented
 - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
 - **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。
 - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。
-- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。
+- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。仅保留未知名称仍会替换工具文档中的已注册名称。
 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。
 - **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md)中被否决。
 
@@ -60,7 +60,7 @@ Status: implemented
 
 - tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/shell/web 指导。
 - fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。
-- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
+- 在启用插值的段中,未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
 - 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。
 
 ## 后果
@@ -69,4 +69,4 @@ Status: implemented
 - `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
 - 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
 - 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望明确暴露的撰写错误。
-- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加
+- 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的 `ptc-turn` 场景在模型可见的提示词中保留工具模板示例

+ 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: e009e66ead25ef0a5e6001d33663e32bc04d19d2
+2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4

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

@@ -0,0 +1,39 @@
+# 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.
+
+The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
+
+The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing.

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

@@ -0,0 +1,39 @@
+# 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` 无法激活时以非零码退出,且不报告就绪。
+
+[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
+
+矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。

+ 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 服务器固定了解析出的目标和投影后的句柄文本,本地存储测试把真实图片缩放到目标尺寸。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-07-deepseek-messages-adapter.md
+2026-09-07-deepseek-messages-adapter.md: 981ca1ec47f7faa3bcabf5db54393a73474e846c
+2026-09-07-deepseek-messages-adapter.zh.md: 8a1059cfa561b6576518c1ce454f0857450682c8

+ 39 - 0
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -0,0 +1,39 @@
+# Agent Note: DeepSeek through the Anthropic Messages protocol
+
+Status: implemented
+
+English | [中文](2026-09-07-deepseek-messages-adapter.zh.md)
+
+## Problem
+
+Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
+
+## Decision
+
+The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, and capability resolution; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `chat-completions`. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
+
+The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort.
+
+Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
+
+Image requests use bounded inline base64 versions from the attachment service. Shared attachment offload and DeepSeek token measurement keep request and measurement policy consistent. Files uploads remain outside this adapter because their endpoints and cache ownership differ from chat-completions; adding them requires a Messages-specific lifetime and error policy.
+
+System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
+
+Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries.
+
+## Alternatives considered
+
+**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure.
+
+**Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently.
+
+**Persist complete native responses or flatten thinking into text.** Full responses duplicate logged content and complicate truncation alignment. Flattening changes the next model input. Minimal aligned replay metadata preserves the missing protocol information without a new Session format.
+
+**Always rewrite the top-level system prompt.** This discards the cache-preserving native update path on capable routes. Explicit capability selection keeps that path while retaining ordinary replacement for other endpoints; converting system instructions to user text would also lose their priority.
+
+## Consequences
+
+The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. The shared base and Web default to Chat Completions; Messages requires explicit opt-in.
+
+Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.

+ 39 - 0
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 通过 Anthropic Messages 协议调用 DeepSeek
+
+Status: implemented
+
+[English](2026-09-07-deepseek-messages-adapter.md) | 中文
+
+## 问题
+
+部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
+
+## 决策
+
+[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录和能力解析;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `chat-completions`。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
+
+适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。
+
+助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
+
+图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。
+
+系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
+
+Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。
+
+## 考虑过的替代方案
+
+**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。
+
+**把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。
+
+**持久化完整原生响应,或把思考压平为文本。** 完整响应重复已记录内容,并使截断对齐复杂化。压平会改变下一次模型输入。最小化的对齐回放元数据可以保留缺失的协议信息,无需新增 Session 格式。
+
+**始终重写顶层系统提示词。** 这会丢弃支持该能力的路由上能够保留缓存的原生更新方式。显式选择能力既保留该方式,也为其他端点保留普通替换;将 system 指令转成 user 文本还会失去其优先级。
+
+## 结果
+
+该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 与 Web 默认使用 Chat Completions;Messages 需要显式启用。
+
+验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.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-connection-indicator-refinements.md
+2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b
+2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md

@@ -0,0 +1,29 @@
+# Agent Note: Connection indicator state and interaction refinements
+
+Status: implemented
+
+English | [中文](2026-09-10-connection-indicator-refinements.zh.md)
+
+## Problem
+
+The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition.
+
+## Decision
+
+**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha.
+
+**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration.
+
+## Alternatives considered
+
+**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them.
+
+**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra.
+
+**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates.
+
+**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label.
+
+## Consequences
+
+`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 连接指示器状态与交互细化
+
+Status: implemented
+
+[English](2026-09-10-connection-indicator-refinements.md) | 中文
+
+## Problem
+
+侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。
+
+## Decision
+
+**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。
+
+**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。
+
+## Alternatives considered
+
+**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。
+
+**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。
+
+**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。
+
+**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。
+
+## Consequences
+
+`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。

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

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
-README.zh.md: 553f28f3af78b18658701449678de80b086dbb15
+README.md: cfab7a4983c31a21b68157c9cefb38c1654571ff
+README.zh.md: 67d07fba1fc27bca9c2a696daddde88aac3aa39c

+ 4 - 2
apps/cli/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
+The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, and fatal configuration or boot failures exit nonzero.
 
 ## Entry modes
 
@@ -45,7 +45,7 @@ Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (
 
 Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
 
-The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
+The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR.
 
 ## Optional overlays
 
@@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 ## Development
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
+
+The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

+ 4 - 2
apps/cli/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
+`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项,以及致命的配置或启动错误都会以非零状态退出。
 
 ## 入口模式
 
@@ -45,7 +45,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
 
-层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。
+层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。
 
 ## 可选覆盖层
 
@@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 ## 开发
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
+
+[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 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

@@ -271,19 +271,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 })
+    }
+  })
+})

+ 404 - 0
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -0,0 +1,404 @@
+/** Failure policy through the built Web process and native configuration watcher. */
+
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { createServer } from 'node:http'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+import { FiberState } from '@deepseek-ai/cordis'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const bin = join(repoRoot, 'apps/cli/lib/bin.js')
+const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html'))
+const failures = [
+  ['import', 'missing.mjs'],
+  ['module evaluation', 'matrix module evaluation'],
+  ['schema', 'matrix schema failure'],
+  ['config expression', 'matrix config expression'],
+  ['disabled expression', 'matrix disabled expression'],
+  ['sync apply', 'matrix sync apply'],
+  ['async apply', 'matrix async apply'],
+  ['dependency', 'matrixMissingService'],
+] as const
+type Failure = typeof failures[number][0]
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-'))
+  const home = join(root, 'home')
+  mkdirSync(home)
+  const events = join(root, 'events')
+  const diagnostics = join(root, 'diagnostics')
+  const serverUrl = join(root, 'server-url')
+  const states = join(root, 'states')
+  const stop = join(root, 'stop')
+  const patch = join(home, 'cordis.patch.yml')
+  const watcher = join(root, 'watcher.patch.yml')
+  // Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression.
+  writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: {
+    root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
+  } }]) + '\n')
+  writeFileSync(events, '')
+  writeFileSync(diagnostics, '')
+  writeFileSync(states, '{}')
+  const observerPath = join(root, 'observer.mjs')
+  // HMR reports through Cordis logger exporters; WARN is above their default INFO threshold.
+  writeFileSync(observerPath, [
+    "import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'",
+    "import { inspect } from 'node:util'",
+    'export function apply(ctx, config) {',
+    '  const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")',
+    '  ctx.logger.buffer.forEach(report)',
+    '  ctx.logger.exporter({ levels: { default: 3 }, export: report })',
+    '  const timer = setInterval(() => {',
+    '    const loader = ctx.get("loader")',
+    '    if (!loader) return',
+    '    writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))',
+    '    renameSync(config.states + ".tmp", config.states)',
+    '  }, 20)',
+    '  ctx.effect(() => () => { clearInterval(timer) })',
+    '  ctx.inject(["webServer", "connection"], scope => {',
+    '    writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
+    '    scope.effect(() => () => { rmSync(config.url, { force: true }) })',
+    '  })',
+    '}',
+    '',
+  ].join('\n'))
+  const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } }
+  const plugin = join(root, 'probe.mjs')
+  writeFileSync(plugin, [
+    "import { appendFileSync, existsSync } from 'node:fs'",
+    'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {',
+    '  return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }',
+    '} } }',
+    'export const apply = (ctx, config) => {',
+    '  if (config.mode === "sync apply") throw new Error("matrix sync apply")',
+    '  return activate(ctx, config)',
+    '}',
+    'async function activate(ctx, config) {',
+    '  if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }',
+    '  if (config.provider) ctx.provide(config.provider, true)',
+    '  if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })',
+    '  appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)',
+    '  const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)',
+    '  ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n')
+  const url = pathToFileURL(plugin).href
+  const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop })
+  const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) })
+  const target = (id: string, failure?: Failure, generation = 1) => ({
+    id,
+    name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href
+      : failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url,
+    ...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}),
+    config: config('target', generation, failure),
+  })
+  const render = (id: string, failure?: Failure, generation = 1) => {
+    let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}`
+    if (failure === 'config expression') text += `\n- id: ${id}\n  config: !!js "(() => { throw new Error('matrix config expression') })()"\n`
+    if (failure === 'disabled expression') text += `\n- id: ${id}\n  disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n`
+    return text + '\n'
+  }
+  writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n')
+  return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
+}
+
+function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
+  const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
+    cwd: f.root,
+    env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
+    input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
+  })
+  let stdout = ''
+  let stderr = ''
+  child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text })
+  child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text })
+  let exited = false
+  void child.then(() => { exited = true })
+  async function wait(predicate: () => boolean) {
+    try {
+      await expect.poll(() => {
+        if (exited) throw new Error('Web process exited')
+        return predicate()
+      }, { timeout: 45_000 }).toBe(true)
+    } catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) }
+  }
+  async function serves(currentServer = false) {
+    await wait(() => /dsh web: http:\/\//u.test(stdout))
+    const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
+    if (!url) throw new Error('Missing Web URL')
+    const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) })
+    const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
+    if (!cookie) throw new Error('Missing Web authentication cookie')
+    const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
+    expect(response.status).toBe(200)
+    const html = await response.text()
+    expect(html).toContain('__DSH_BOOT__')
+    const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&amp;', '&')
+    if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
+    const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
+    expect(bundle.status).toBe(200)
+    expect(await bundle.text()).not.toBe('')
+  }
+  async function close() {
+    writeFileSync(f.stop, 'stop')
+    // Assertions may fail before a probe applies; forceful teardown still awaits exit.
+    const timer = setTimeout(() => child.kill('SIGKILL'), 10_000)
+    try {
+      const result = await child
+      return { timedOut: result.timedOut, signal: result.signal, exitCode: result.exitCode, stderr: result.stderr, events: readFileSync(f.events, 'utf8') }
+    } finally {
+      clearTimeout(timer)
+      rmSync(f.root, { recursive: true, force: true })
+    }
+  }
+  const state = (id: string) => (JSON.parse(readFileSync(f.states, 'utf8')) as Record<string, number | undefined>)[id]
+  return { child, wait, serves, close, state, stderr: () => stderr, logs: () => readFileSync(f.diagnostics, 'utf8'), events: () => readFileSync(f.events, 'utf8') }
+}
+
+function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode?: number | undefined; stderr: string }, code: number) {
+  expect(result.timedOut, result.stderr).toBe(false)
+  expect(result.signal, result.stderr).toBeUndefined()
+  expect(result.exitCode, result.stderr).toBe(code)
+}
+
+describe.skipIf(!built)('Web process failure matrix', () => {
+  for (const required of [false, true]) {
+    const id = required ? 'acp' : 'matrix-optional'
+    it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
+      const f = fixture()
+      writeFileSync(f.patch, f.render(id, failure))
+      const app = start(f)
+      try {
+        if (required) {
+          const result = await app.child
+          exit(result, 1)
+          expect(result.stdout).not.toContain('dsh web: http://')
+          expect(result.stderr).toContain('required startup failure')
+          expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
+        } else {
+          await app.serves()
+          await app.wait(() => (app.stderr() + app.logs()).includes(diagnostic))
+          expect(app.stderr()).toContain('warning: 1 entry did not activate')
+          expect(app.events()).toBe('witness apply 1\n')
+        }
+        expect(app.stderr() + app.logs()).toContain(diagnostic)
+      } finally {
+        const result = await app.close()
+        exit(result, required ? 1 : 0)
+        expect(result.events).toContain('witness dispose 1\n')
+      }
+    })
+
+    it.each(failures)(`${required ? 'required' : 'optional'} native HMR %s keeps siblings and recovers`, async (failure, diagnostic) => {
+      const f = fixture()
+      const app = start(f)
+      try {
+        await app.serves()
+        writeFileSync(f.patch, f.render(id, failure))
+        await app.wait(() => app.events().includes('witness apply 1\n') && (failure === 'dependency' ? app.state(id) === FiberState.PENDING : app.logs().includes(diagnostic)))
+        expect(app.events()).not.toContain('witness dispose 1\n')
+        expect(app.events()).not.toContain('target apply')
+        expect(readFileSync(f.patch, 'utf8')).toBe(f.render(id, failure))
+        await app.serves()
+        if (failure === 'dependency') {
+          writeFileSync(f.patch, JSON.stringify([
+            { insert: [f.observer, f.witness(1), f.target(id, failure)] },
+            { insert: [{ id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 2), provider: 'matrixMissingService' } }] },
+          ]) + '\n')
+        } else writeFileSync(f.patch, f.render(id, undefined, 2))
+        await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`))
+        await app.serves()
+        expect(app.stderr()).not.toContain('required startup failure')
+      } finally {
+        const result = await app.close()
+        exit(result, 0)
+        expect(result.events).toContain(`witness dispose ${failure === 'dependency' ? 1 : 2}\n`)
+        expect(result.events).toContain(`target dispose ${failure === 'dependency' ? 1 : 2}\n`)
+      }
+    })
+  }
+
+  it.each([
+    ['missing', undefined, 'failed to read overlay'],
+    ['unreadable directory', undefined, 'failed to read overlay'],
+    ['malformed', 'invalid: [unclosed\n', 'failed to parse overlay'],
+    ['non-array', 'entries: []\n', 'top-level YAML array'],
+    ['non-mapping', '- null\n', 'must be a mapping'],
+  ])('rejects a %s explicit overlay before readiness', async (_kind, content, diagnostic) => {
+    const f = fixture()
+    const overlay = join(f.root, 'invalid.patch.yml')
+    if (_kind === 'unreadable directory') mkdirSync(overlay)
+    else if (content !== undefined) writeFileSync(overlay, content)
+    const app = start(f, ['--patch', overlay])
+    try {
+      const result = await app.child
+      exit(result, 1)
+      expect(result.stderr).toContain(diagnostic)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(app.events()).toBe('')
+    } finally { exit(await app.close(), 1) }
+  })
+
+  it.each([
+    ['malformed', 'invalid: [unclosed\n', 'failed to parse patches'],
+    ['non-array', 'entries: []\n', 'top-level YAML array'],
+    ['non-mapping', '- null\n', 'must be a mapping'],
+  ])('native HMR rejects %s patches, preserves the app and accepts a correction', async (_kind, content, diagnostic) => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, content)
+      await app.wait(() => app.logs().includes(diagnostic))
+      expect(app.events()).toBe('witness apply 0\n')
+      await app.serves()
+      writeFileSync(f.patch, f.render('matrix-optional'))
+      await app.wait(() => app.events().includes('target apply 1\n'))
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('native HMR schema failure retains the existing config until a valid correction', async () => {
+    const f = fixture()
+    writeFileSync(f.patch, f.render('acp', undefined, 1))
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, f.render('acp', 'schema', 2))
+      await app.wait(() => app.logs().includes('matrix schema failure') && app.events().includes('witness apply 2\n'))
+      expect(app.events()).toContain('target apply 1\n')
+      expect(app.events()).not.toContain('target dispose 1\n')
+      expect(app.events()).not.toContain('target apply 2\n')
+      await app.serves()
+      writeFileSync(f.patch, f.render('acp', undefined, 3))
+      await app.wait(() => app.events().includes('target apply 3\n'))
+      expect(app.events()).toContain('target dispose 1\n')
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('ignores absent and explicitly disabled required entries at startup', async () => {
+    const f = fixture()
+    writeFileSync(f.patch, f.render('acp', 'import') + '- id: acp\n  disabled: true\n')
+    const app = start(f)
+    try {
+      await app.serves()
+      expect(app.stderr()).not.toContain('required startup failure')
+      expect(app.stderr()).not.toContain('failed to import')
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('detached rejection from a hot-loaded plugin terminates and disposes the app', async () => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, JSON.stringify([{ insert: [f.observer, f.witness(0), {
+        id: 'matrix-detached', name: f.url, config: f.config('detached', 1, 'detached'),
+      }] }]) + '\n')
+      const result = await app.child
+      exit(result, 1)
+      expect(result.stderr).toContain('fatal load failure: Error: matrix detached failure')
+      expect(app.events()).toContain('witness dispose 0\n')
+      expect(app.events()).toContain('detached dispose 1\n')
+    } finally { exit(await app.close(), 1) }
+  })
+
+  it('native HMR reports a required Web server bind failure without terminating the process', async () => {
+    const f = fixture()
+    const blocker = createServer()
+    const app = start(f)
+    try {
+      await new Promise<void>((resolve, reject) => {
+        blocker.once('error', reject)
+        blocker.listen(0, '127.0.0.1', resolve)
+      })
+      const address = blocker.address()
+      if (!address || typeof address === 'string') throw new Error('Missing blocker address')
+      await app.serves()
+      writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n  config:\n    host: 127.0.0.1\n    port: ${address.port}\n`)
+      await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n'))
+      expect(app.stderr()).not.toContain('required startup failure')
+      expect(app.events()).not.toContain('witness dispose 1\n')
+      expect(existsSync(f.serverUrl)).toBe(false)
+      writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
+      await app.wait(() => app.events().includes('witness apply 2\n') && existsSync(f.serverUrl))
+      await app.serves(true)
+    } finally {
+      try { exit(await app.close(), 0) } finally {
+        await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
+      }
+    }
+  })
+
+  it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
+    const f = fixture()
+    const blocker = createServer()
+    let app: ReturnType<typeof start> | undefined
+    try {
+      const plugin = join(f.root, 'http.mjs')
+      writeFileSync(plugin, [
+        'import { createServer } from "node:http"',
+        'export async function apply(ctx, config) {',
+        '  const server = createServer()',
+        '  ctx.effect(() => () => new Promise(resolve => server.close(() => resolve())))',
+        '  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.port, "127.0.0.1", resolve) })',
+        '}',
+        '',
+      ].join('\n'))
+      await new Promise<void>((resolve, reject) => {
+        blocker.once('error', reject)
+        blocker.listen(0, '127.0.0.1', resolve)
+      })
+      const address = blocker.address()
+      if (!address || typeof address === 'string') throw new Error('Missing blocker address')
+      const patch = JSON.stringify([{ insert: [f.observer, f.witness(0), {
+        id: 'matrix-optional-http', name: pathToFileURL(plugin).href, config: { port: address.port },
+      }] }]) + '\n'
+      if (phase === 'startup') writeFileSync(f.patch, patch)
+      app = start(f)
+      const running = app
+      await app.serves()
+      if (phase === 'HMR') writeFileSync(f.patch, patch)
+      await app.wait(() => (running.logs() + running.stderr()).includes('EADDRINUSE'))
+      expect(app.events()).toBe('witness apply 0\n')
+      await app.serves()
+    } finally {
+      try {
+        if (app) exit(await app.close(), 0)
+        else rmSync(f.root, { recursive: true, force: true })
+      } finally {
+        if (blocker.listening) {
+          await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
+        }
+      }
+    }
+  })
+
+  it.each(['modules', 'connection'])('native HMR recovers the shipped required %s entry', async (id) => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      // Entry-level injection requirements are captured when the fiber is created.
+      writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n  disabled: true\n`)
+      await app.wait(() => app.state(id) === FiberState.DISPOSED)
+      const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
+      const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n  inject: ${JSON.stringify(inject)}\n`
+      writeFileSync(f.patch, pending)
+      await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
+      expect(app.events()).not.toContain('witness dispose 2\n')
+      expect(app.stderr()).not.toContain('required startup failure')
+      writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
+        id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
+      }])}\n`)
+      await app.wait(() => app.state(id) === FiberState.ACTIVE && existsSync(f.serverUrl))
+      await app.serves(true)
+    } finally { exit(await app.close(), 0) }
+  })
+})

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

+ 75 - 0
apps/web/tests/deepseek-messages-chat.e2e.ts

@@ -0,0 +1,75 @@
+/** Historical Messages provider replay preserves its recorded identity and DeepSeek model group. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/deepseek-messages-chat', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let replayFixture: string
+
+  beforeAll(async () => {
+    replayFixture = await selectedSessionFixture(FIXTURE, false)
+    scaffold = await launchWebScaffold({
+      deepSeekMessages: true,
+      replayFixture,
+      paceMs: 5,
+      replayProviders: [{
+        id: 'deepseek-messages', name: 'DeepSeek',
+        models: [{
+          id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash',
+          contextWindow: 1_000_000, defaultMaxTokens: 256_000,
+          reasoningEfforts: ['off', 'low', 'high', 'max'], defaultReasoningEffort: 'high',
+        }],
+      }],
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    try { await browser?.close() } finally { await scaffold?.close() }
+  })
+
+  it('replays the historical Messages provider while displaying DeepSeek in the model selector', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-chat'))
+    const prompts = fixtureUserPrompts(await readFile(replayFixture, 'utf8'))
+    expect(prompts).toHaveLength(1)
+    expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByText('DeepSeek', { exact: true }).waitFor()
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    const input = page.locator('[data-composer-input]').first()
+    const settled = scaffold.whenTurnSettled()
+    await input.fill(prompts[0]!)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.sessions.get(sessionId)!
+    expect(session.requestHeader()?.config.provider).toBe('deepseek-messages')
+    await page.getByText('MESSAGES_WEB_READY', { exact: true }).waitFor()
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'),
+      await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('keeps the recorded-session inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
+  })
+})

+ 103 - 0
apps/web/tests/deepseek-messages-settings.e2e.ts

@@ -0,0 +1,103 @@
+/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
+
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+  }, 120_000)
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      await scaffold?.close()
+    }
+  })
+
+  it('offers one DeepSeek card and saves Messages settings using the existing credential reference', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings'))
+    expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' })
+    expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1)
+    expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
+    const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
+    await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding')
+    await onboarding.getByRole('button', { name: '保存并继续' }).click()
+    await onboarding.waitFor({ state: 'detached' })
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置', exact: true })
+    await dialog.getByRole('button', { name: '模型', exact: true }).click()
+    await dialog.getByText('DeepSeek', { exact: true }).waitFor()
+    expect(await dialog.getByText('DeepSeek', { exact: true }).count()).toBe(1)
+    await dialog.getByText('DeepSeek', { exact: true }).locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
+    const messages = dialog
+    await messages.getByText('自定义设置', { exact: true }).click()
+    expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
+      .toBe('https://api.deepseek.com/anthropic')
+    await compareOrRefreshGolden(join(EXPECTED, 'cards.expected.md'),
+      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
+    await messages.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-messages')
+    await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic')
+    expect(await messages.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
+    await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash')
+    await messages.getByRole('button', { name: '保存', exact: true }).click()
+    await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
+
+    const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(settings).toContain('https://messages.example/anthropic')
+    expect(settings).toContain('llm-deepseek:')
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
+      name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
+    })
+    expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' })
+    expect(settings).not.toContain('sk-e2e-')
+    const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
+    expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
+    expect(credentials).not.toContain('DEEPSEEK_MESSAGES_API_KEY')
+    expect(await page.locator('body').innerText()).not.toContain('sk-e2e-')
+    await page.keyboard.press('Escape')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e')
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor()
+    await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'),
+      await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
+    await page.keyboard.press('Escape')
+    await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
+    await page.reload({ waitUntil: 'load' })
+    const input = page.locator('[data-composer-input]').first()
+    await expect.poll(() => input.isEnabled()).toBe(true)
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click()
+    await expect.poll(() => input.isEnabled()).toBe(true)
+    await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official')
+    const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(settings).toContain('provider: deepseek-official')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+})

+ 87 - 0
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -0,0 +1,87 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: DeepSeek
+      - img "API 密钥已配置"
+      - button "编辑 DeepSeek (deepseek-official)": 编辑
+      - text: DeepSeek deepseek-official API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 已配置——输入新值可替换
+      - group:
+        - text: 自定义设置 API 地址
+        - textbox "API 地址":
+          - /placeholder: https://api.deepseek.com/anthropic
+        - text: 请填写与当前连接配置兼容的 API 地址。
+        - region "模型目录":
+          - text: 模型目录 正在使用适配器默认模型
+          - textbox "模型 ID 1":
+            - /placeholder: 模型 ID
+            - text: deepseek-flash
+          - textbox "显示名称 1":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V41-Flash
+          - button "容量 1":
+            - img
+          - button "删除模型 1":
+            - img
+          - textbox "模型 ID 2":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash
+          - textbox "显示名称 2":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash
+          - button "容量 2":
+            - img
+          - button "删除模型 2":
+            - img
+          - textbox "模型 ID 3":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-pro
+          - textbox "显示名称 3":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Pro
+          - button "容量 3":
+            - img
+          - button "删除模型 3":
+            - img
+          - textbox "模型 ID 4":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash-vision-exp
+          - textbox "显示名称 4":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash-Vision-Exp
+          - button "容量 4":
+            - img
+          - button "删除模型 4":
+            - img
+          - button "添加模型":
+            - img
+            - text: 添加模型
+      - button "取消"
+      - button "保存"
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

+ 9 - 0
apps/web/tests/expected/deepseek-messages-settings/picker.expected.md

@@ -0,0 +1,9 @@
+- menu "模型与推理等级":
+  - group "DeepSeek":
+    - text: DeepSeek
+    - menuitemradio "Messages Flash" [checked]:
+      - text: Messages Flash
+      - img
+    - menuitemradio "DeepSeek-V4-Flash"
+    - menuitemradio "DeepSeek-V4-Pro"
+    - menuitemradio "DeepSeek-V4-Flash-Vision-Exp"

+ 1 - 0
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -31,6 +31,7 @@
         - text: 自定义设置 API 地址
         - textbox "API 地址":
           - /placeholder: https://api.deepseek.com
+        - text: 请填写与当前连接配置兼容的 API 地址。
         - region "模型目录":
           - text: 模型目录 正在使用适配器默认模型
           - textbox "模型 ID 1":

+ 1 - 0
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -31,6 +31,7 @@
         - text: 自定义设置 API 地址
         - textbox "API 地址":
           - /placeholder: https://api.deepseek.com
+        - text: 请填写与当前连接配置兼容的 API 地址。
         - region "模型目录":
           - text: 模型目录 已自定义模型目录
           - button "恢复默认模型"

+ 8 - 15
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -428,22 +428,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await recoveryPage.context().setOffline(false)
       await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
       const connecting = recoveryPage.getByRole('button', {
-        name: 'Reconnecting automatically, reconnect now', exact: true,
+        name: 'Reconnecting, reconnect now', exact: true,
       })
       await connecting.waitFor({ timeout: 10_000 })
       expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const connectingGeometry = await connectionIndicatorGeometry(connecting)
       expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
-      // Animated dots must remain hidden with their state label during hover.
-      await connecting.evaluate((element) => {
-        for (const animation of element.getAnimations({ subtree: true })) {
-          if (!(animation instanceof CSSAnimation)) continue
-          animation.pause()
-          animation.currentTime = 1_250
-        }
-      })
+      // Hover keeps the state label; the pill never swaps copy or resizes.
       await connecting.hover()
-      expect(await connecting.innerText()).toBe('Reconnect now')
+      expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
       await recoveryPage.mouse.move(0, 0)
 
@@ -461,7 +454,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       const indicator = connecting
       expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
       expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
-      await indicator.hover()
       const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
       const style = await indicator.evaluate((element) => {
@@ -498,11 +490,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await connecting.waitFor()
       await recoveryPage.clock.fastForward(500)
       await expect.poll(() => sockets.length).toBe(11)
-      const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
       await indicator.hover()
-      expect(await indicator.innerText()).toBe('Reconnect now')
+      expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
-      expect(hoverBackground).toBe(idleBackground)
       await recoveryPage.mouse.down()
       await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
         .not.toBe(hoverBackground)
@@ -513,7 +503,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       const recovered = recoveryPage.getByRole('status')
       await recovered.waitFor({ timeout: 10_000 })
       expect(await recovered.innerText()).toBe('Connected')
-      expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
+      // The pill sizes to its current label; chrome height and icon box stay fixed.
+      const recoveredGeometry = await connectionIndicatorGeometry(recovered)
+      expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3])
+      expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon)
       expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
       await recoveryPage.clock.fastForward(2_000)
       await recovered.waitFor({ state: 'detached', timeout: 5_000 })

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

+ 30 - 20
apps/web/tests/scaffold.ts

@@ -5,7 +5,7 @@
 // layer stack the profile boot composes), patched the
 // snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
 // downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
-// replay (default, keyless: normally disables the llm-deepseek row and
+// replay (default, keyless: normally disables the direct DeepSeek rows and
 // inserts dsh-llm-replay in providers mode), record (real adapter + key,
 // harvests fixtures from live session memory), refresh (keyless replay that
 // rewrites goldens). A first-run option keeps the real adapter mounted while
@@ -18,7 +18,7 @@
 // disabled (recorded fixtures must not embed this repo's AGENTS.md);
 // session-title-llm disabled (its fire-and-forget title call would race the
 // loop for the session's replay cursor); webserver pinned to port 0 with the
-// built dist; ordinary keyless modes disable llm-deepseek and fill the open
+// built dist; ordinary keyless modes disable both direct adapters and fill the open
 // llm seam post-boot with installLlmReplay on the settled root ctx
 // (the plugin-row path discards the ReplayHandle; the direct install keeps
 // assertConsumed for the teardown fixture-consumption check).
@@ -57,7 +57,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
-  assertEntriesLoaded,
+  auditStartupEntries,
   composeEntries,
   healProfilesModuleFallback,
   loadOverlayPatches,
@@ -187,7 +187,7 @@ const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml
 const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 
 // Replay publishes the provider catalog the gateway routes to (providers
-// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
+// mode, never catch-all: with both direct adapters disabled no adapter exists, so a
 // catch-all would leave resolveModelInfo unroutable and compaction-basic's
 // post-step pressure check would warn every step). The published
 // contextWindow keeps that pressure path provably inert for small fixtures.
@@ -248,11 +248,14 @@ class RouteOnlyAdapter extends LlmAdapter {
   }
 }
 
-function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
-  if (contextWindow === undefined) return REPLAY_PROVIDERS
+function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
   return REPLAY_PROVIDERS.map(provider => ({
     ...provider,
-    models: provider.models.map(model => ({ ...model, contextWindow })),
+    id: messages ? 'deepseek-messages' : provider.id,
+    models: provider.models.map(model => ({
+      ...model,
+      ...contextWindow === undefined ? {} : { contextWindow },
+    })),
   }))
 }
 
@@ -305,7 +308,7 @@ export interface LaunchOptions {
    * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
    * in replay/refresh modes; ignored in record mode (the real adapter
    * answers). Omit for scenarios issuing no model calls — a stray stream then
-   * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
+   * fails loud with NO_ADAPTER (both direct adapters are disabled and no replay row
    * mounts). With {@link replayProvidersOnly}, the fixture must record no
    * model calls (its header alone mounts the catalog).
    */
@@ -362,6 +365,8 @@ export interface LaunchOptions {
    * keyless first-run configuration lane; the default disables the adapter.
    */
   deepSeekMissingCredential?: boolean
+  /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
+  deepSeekMessages?: boolean
   /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
   welcomeNoticePending?: boolean
   /**
@@ -443,6 +448,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
   }
   const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
+  const messages = options.deepSeekMessages === true
   const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
   let credentialEnvironmentRestored = false
   const restoreCredentialEnvironment = (): void => {
@@ -516,10 +522,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   const patches: PatchOptions[] = [
     ...basePatches,
     ...surfacePatches,
-    // Keyless scenarios retain the recorded default; explicit scenario overlays win.
-    ...mode === 'record' || options.deepSeekMissingCredential === true
-      ? []
-      : [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
+    // The historical Messages fixture retains its recorded route during replay;
+    // live configuration uses the shared DeepSeek route. Explicit overlays win.
+    ...messages
+      ? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
+      : mode === 'record' || options.deepSeekMissingCredential === true
+        ? []
+        : [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
     ...extraOverlayPatches,
     // The roster's shipped presets are the plugin's own, bundled inside
     // `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -644,9 +653,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
           baseURL: options.deepSeekSearch.baseURL,
         },
       }],
-    ...mode === 'record' || options.deepSeekMissingCredential === true
-      ? []
-      : [{ id: 'llm-deepseek', disabled: true }],
+    ...maskDeepSeekCredential && !messages ? [] : [
+      { id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
+        config: { protocol: messages ? 'messages' : 'chat-completions' } },
+    ],
   ]
 
   // Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -722,7 +732,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,
@@ -735,7 +745,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     port = boundPort
 
     // Fill the open llm seam on the settled root ctx. Ordinary keyless modes
-    // disable llm-deepseek; the first-run lane keeps it mounted but has no
+    // disable the direct adapter; the first-run lane keeps the selected adapter but has no
     // replay fixture and never streams. The direct install, unlike the plugin
     // row, returns the ReplayHandle for the teardown consumption check.
     if (options.replayProvidersOnly) {
@@ -772,7 +782,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     if (mode !== 'record' && replayFixture !== undefined) {
       replayHandle = installLlmReplay(ctx, {
         file: replayFixture,
-        providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
+        providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
           ...provider,
           ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
         })),
@@ -787,8 +797,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       // a fixture would, with streaming that still fails loud: the scenario
       // issues no model calls, and one that slipped in must not pass quietly.
       ctx.effect(() => ctx.llm.registerAdapter(
-        replayProviders(options.replayContextWindow).map(provider => provider.id),
-        new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
+        replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
+        new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
       ), 'web e2e scaffold: route-only adapter')
     }
     baseUrl = `http://${browserHost}:${String(port)}`

+ 2 - 0
apps/web/tests/shipped-composition.e2e.ts

@@ -79,6 +79,8 @@ afterEach(async () => {
 it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
   scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
   const ctx = scaffold.ctx
+  expect(ctx.llm.listProviders().some(provider => provider.id === 'deepseek-messages')).toBe(false)
+  expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
   const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, {
     headers: { 'accept-encoding': 'gzip' },
   })

+ 2 - 0
apps/web/tsconfig.json

@@ -44,6 +44,8 @@
     "tests/plugin-config.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
+    "tests/deepseek-messages-settings.e2e.ts",
+    "tests/deepseek-messages-chat.e2e.ts",
     "tests/models-settings-recovery.e2e.ts",
     "tests/default-model.e2e.ts",
     "tests/github-ready-review.e2e.ts",

+ 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: bba110d61496fba2f69383f9375613fd369fad03
-config-catalog.zh.md: 01054e3dda0a36ab79cc00d1ee86ede2e4fc8e32
+config-catalog.md: 8c1cf53597364af9dae4a679e87624ea358b6996
+config-catalog.zh.md: e4f21204ee2bb77d869a1e4645456d39cf6558b3

+ 12 - 3
docs/config-catalog.md

@@ -1025,6 +1025,8 @@ Requires: `llm`
  * reasoning effort resolves to `high`.
  */
 export interface Config {
+  /** Wire protocol; defaults to chat-completions. Configure through Cordis YAML. */
+  protocol?: DeepSeekProtocol
   /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
   apiKeyEnv?: string
   /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
@@ -1065,6 +1067,9 @@ export interface Config {
   retryPolicy?: RetryPolicyConfig
 }
 
+/** Supported wire implementations; Responses is not yet implemented. */
+export type DeepSeekProtocol = 'chat-completions' | 'messages'
+
 /** One optional model entry advertised by the direct-fetch adapter. */
 export interface DeepSeekCatalogModel {
   /** Wire model id accepted by the configured endpoint. */
@@ -1079,7 +1084,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
@@ -1094,7 +1103,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/config.ts:25`](../packages/llm/llm-deepseek/src/config.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 
@@ -2607,7 +2616,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 

+ 14 - 5
docs/config-catalog.zh.md

@@ -1015,7 +1015,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-llm-deepseek`
 
-需要:`llm`
+需要: `llm`
 
 ```ts config-catalog
 /**
@@ -1027,6 +1027,8 @@ export interface Config {
  * reasoning effort resolves to `high`.
  */
 export interface Config {
+  /** Wire protocol; defaults to chat-completions. Configure through Cordis YAML. */
+  protocol?: DeepSeekProtocol
   /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
   apiKeyEnv?: string
   /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
@@ -1067,6 +1069,9 @@ export interface Config {
   retryPolicy?: RetryPolicyConfig
 }
 
+/** Supported wire implementations; Responses is not yet implemented. */
+export type DeepSeekProtocol = 'chat-completions' | 'messages'
+
 /** One optional model entry advertised by the direct-fetch adapter. */
 export interface DeepSeekCatalogModel {
   /** Wire model id accepted by the configured endpoint. */
@@ -1081,7 +1086,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
@@ -1094,9 +1103,9 @@ export interface DeepSeekCatalogModel {
 }
 ```
 
-依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
+依赖: [`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/config.ts:25`](../packages/llm/llm-deepseek/src/config.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 
@@ -2609,7 +2618,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+来源:[`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></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: 180144f94eb1ce1fa538dc98c813c6c2b883c288
-event-producer-consumer.zh.md: 300a2fe2605f2451052d24c43e6c2c50b06aa996
+event-producer-consumer.md: 1f4e613948f72e26215619494b670374f0f3f58e
+event-producer-consumer.zh.md: 6e3090abcd2fc5c82127fb0f93a97be7be4d75a2

+ 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/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
 /**

+ 2 - 2
docs/subsystems/system-prompt.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/system-prompt.md
-system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753
-system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e
+system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210
+system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145

+ 3 - 1
docs/subsystems/system-prompt.md

@@ -56,9 +56,11 @@ interface PromptSection {
   /**
    * Static text or a provider evaluated at each assembly with that assembly's
    * {@link AssembleContext}. The text may reference `{{variable}}`s — they are
-   * interpolated later, by {@link renderPrompt}.
+   * interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
    */
   readonly text: string | ((context: AssembleContext) => string)
+  /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
+  readonly interpolate?: boolean
   /**
    * Treat this contribution as the complete system prompt. Assembly still
    * runs the cooperative waterfall so tools, contexts, and variables can be

+ 3 - 1
docs/subsystems/system-prompt.zh.md

@@ -56,9 +56,11 @@ interface PromptSection {
   /**
    * Static text or a provider evaluated at each assembly with that assembly's
    * {@link AssembleContext}. The text may reference `{{variable}}`s — they are
-   * interpolated later, by {@link renderPrompt}.
+   * interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
    */
   readonly text: string | ((context: AssembleContext) => string)
+  /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
+  readonly interpolate?: boolean
   /**
    * Treat this contribution as the complete system prompt. Assembly still
    * runs the cooperative waterfall so tools, contexts, and variables can be

+ 0 - 1
package.json

@@ -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",

+ 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` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
 

+ 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

+ 2 - 2
packages/attachment/attachment/README.md

@@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera
 - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization.
 - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
 - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed.
-- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path.
+- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path.
 - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain.
 - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route.
 
 ### Service operations
 
-The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
+The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
 
 ### Source map
 

+ 2 - 2
packages/attachment/attachment/README.zh.md

@@ -66,13 +66,13 @@ kind: "package-reference"
 - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。
 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。
 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。
-- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
+- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。
 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。
 
 ### 服务操作
 
-服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
+服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
 
 ### 源码地图
 

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

@@ -10,7 +10,7 @@ import type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveFileAttachment,
   SaveFileStreamAttachment,
@@ -22,7 +22,8 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
 export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
 export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
 export { admitEncodedFile, admitEncodedImages } from './admission.ts'
-export { requestImageDimensions } from './request-projection.ts'
+export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts'
+export type { ProjectedDimensions } from './request-projection.ts'
 export type {
   AttachmentId as AttachmentIdType,
   AdmittedPromptContentPart,
@@ -32,7 +33,7 @@ export type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   ImageMediaType,
   PromptContentPart,
   RequestImageAttachment,
@@ -241,18 +242,18 @@ export abstract class AttachmentStore extends Service {
   /**
    * 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,
+    target: ImageRequestTarget,
     signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
     signal?.throwIfAborted()
     void ref
-    void policy
+    void target
     return Promise.reject(new AttachmentError(
       'The mounted attachment provider cannot derive model-request images.',
       'ATTACHMENT_PROJECTION_UNSUPPORTED',

Some files were not shown because too many files changed in this diff