diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index af337114a0..f6289cf96f 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/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 .agents/notes/README.md
-README.md: 8bf1adf875b54eb4580b4d189dfeed07c9f671c0
-README.zh.md: ff9bdb358d584d4f4ad0aea295c31d8d8fff9376
+README.md: 6872de0a1390fa5a1707b9f464c9e0edeb2da4be
+README.zh.md: ced81f77fa904ad0b5cb5219383ef651d87392b8
diff --git a/.agents/notes/README.md b/.agents/notes/README.md
index 8bf1adf875..6872de0a13 100644
--- a/.agents/notes/README.md
+++ b/.agents/notes/README.md
@@ -16,7 +16,7 @@ Every Agent Note has two axes, both encoded in its **path** — `{lifecycle}/{cl
The date in the filename is when the topic was **first proposed** (per git history). Cross-references between Agent Notes use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders.
-The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Low-future-value implemented records move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below.
+The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Implemented records with historical decision value but little future guidance move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below.
## Classification
@@ -35,17 +35,17 @@ The `architecture` / `process` line: **architecture** is about the source we shi
## Archiving and deletion
-Archive an implemented Agent Note when the shipped decision is complete and its rationale is unlikely to guide future work. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota.
+Delete an implemented Agent Note that only describes small UI adjustments or purely mechanical changes; delete its English, Chinese, and sidecar files together and repair or remove inbound links. A local bug fix, performance change, new capability, or substantive behavior decision does not qualify merely because its implementation is small. Archive other implemented notes only when the shipped decision is complete, its rationale is unlikely to guide future work, and the record still has historical decision value. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota.
-The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival.
+The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body.
Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
## When to write one
-Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
+Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
-Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
+Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical or local edits, including local UI presentation and interaction changes, are exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale.
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index ff9bdb358d..ced81f77fa 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -16,7 +16,7 @@
文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
-活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。
+活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。具有历史决策价值但未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。
@@ -37,9 +37,9 @@
## 归档与删除
-当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。
+仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 直接删除;一并删除英文、中文和伴随记录文件,并修复或移除入站链接。局部 bug 修复、性能变更、新能力或实质行为决策,不会仅因实现规模小就符合删除条件。其他已实施记录只有在交付决策完整落地、其依据不太可能再指导未来工作,但仍具有历史决策价值时才归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。
-归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。
+归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。
封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
@@ -47,9 +47,9 @@
## 何时需要写一份
-每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包约定、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者维护者可能合理重新审视的其他决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
+只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
-更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
+更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性或局部编辑均可豁免,包括局部 UI 展示和交互调整。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
similarity index 69%
rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
index 8d8ad118e3..b8b5ab8c63 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
+++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
-2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd
+2026-09-09-desktop-in-place-profile.md: 57dabc0d03c999b90dc18baa9d833aa90320863a
+2026-09-09-desktop-in-place-profile.zh.md: 2b57c49ce45b1937552596cf4f59e0251b2d5774
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md
index 6f0e959890..57dabc0d03 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
+++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md
@@ -1,6 +1,7 @@
# Agent Note: Modify the Desktop profile in place
Status: implemented
+Archived: 2026-09-17
English | [中文](2026-09-09-desktop-in-place-profile.zh.md)
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md
index bec79b3b9d..2b57c49ce4 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
+++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md
@@ -1,6 +1,7 @@
# Agent Note: 直接修改 Desktop profile
Status: implemented
+Archived: 2026-09-17
[English](2026-09-09-desktop-in-place-profile.md) | 中文
diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml
similarity index 58%
rename from .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
rename to .agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml
index 209fc7e492..0afd4836d9 100644
--- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
+++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
-2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d
-2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-local-office-preview.md
+2026-09-10-local-office-preview.md: ea32e37f0b2dcba866b78947c785dcee8e475b29
+2026-09-10-local-office-preview.zh.md: a1ac8337a1cf22f5842485c0790c6554635dc125
diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md
new file mode 100644
index 0000000000..ea32e37f0b
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md
@@ -0,0 +1,52 @@
+# Agent Note: Local Office preview through WASM PDF bytes
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-10-local-office-preview.zh.md)
+
+## Problem
+
+Office Open XML files are ZIP archives, so text fallback cannot provide a useful preview. Document conversion must stay local without taking focus or adding document content to a model conversation. Engine success alone cannot prove that the input has the claimed format or that a PDF was produced.
+
+## Decision
+
+A [document-render Service Definition](../../../../packages/document/document-render/README.md), a LibreOffice WASM provider, and Client/API consumers form the conversion capability. [Boot composition](../../../../packages/document/document-render-auto/README.md) mounts the provider and Remote controller only when `wasm.artifactDirectory` is configured. Invalid explicit configuration fails initialization. The Office Client registration always exists so an unconfigured Host receives guidance. Availability follows the generated Remote namespace without executable discovery or a duplicate Client flag.
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) uses pinned official LibreOffice source, Emscripten, and a LibreOfficeKit adapter. Each Node Worker owns one engine instance, its pthreads, and its memory filesystem. Terminating the Worker cancels synchronous engine calls; every conversion waits for its Worker to stop before returning. OOXML inspection verifies archive membership, content type, and resource bounds before startup.
+
+A VCL callback requests a font family before substitution or missing Unicode characters during glyph fallback. Host code indexes installed font metadata and copies selected files into engine memory, including complete font collections. Exact installed families precede configured alternatives; original-first fontconfig aliases preserve that ordering inside LibreOffice. Optional initial families use the same resolver. The engine has no Host filesystem mount, and rendering neither installs nor downloads fonts. The [build recipe](../../../../native/libreoffice-wasm/README.md) records source and toolchain revisions, patches, and asset hashes. Engine bundles remain immutable during provider use; a different build uses a new directory.
+
+The [independent engine release](../process/2026-09-11-independent-libreoffice-package.md) owns precompiled npm packaging and keeps engine compilation outside ordinary DSH builds.
+
+The provider returns caller-owned PDF bytes and independent `succeeded`, `timedOut`, and `cancelled` facts. PDF bytes are copied out of engine memory before teardown. The API consumer authorizes the source through [Workspace Files](2026-09-09-workspace-file-read-authority.md), requires successful uninterrupted conversion, and encodes the PDF directly. Source path and freshness version survive the PDF transport. Workspace Files limits source reads; the provider's `maxOutputBytes` alone limits generated PDFs. No temporary PDF or file lease is needed.
+
+The Office Client watches the selected Conversation's existing Deliverables projection and warms recent Office files without opening tabs. Its bounded memory cache checks authorized source metadata on every read and shares pending conversions between background and foreground callers. Only the last departing reader cancels a shared conversion; failures are not cached and connection reset discards cached bytes. [Document Preview](2026-09-08-document-preview-operations.md) owns format selection, loading, cancellation, and the PDF.js Worker.
+
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
+
+Missing-family observations accompany the PDF through the worker and Remote response. The Office plugin renders a keyed notice above the shared PDF scrollport, so dismissing it removes its layout height. The font resolver intersects explicit source and theme references with actual document requests, excluding engine defaults and font-table inventories; installed aliases and glyph fallback do not by themselves mean a family is absent.
+
+## Alternatives considered
+
+**Keep an installed native LibreOffice provider.** A second provider adds executable discovery, version queries, subprocess supervision, scratch files, and platform-specific confinement. This design uses WASM for all supported Office previews. A native provider needs a demonstrated rendering or deployment requirement that WASM cannot satisfy.
+
+**Return a temporary PDF path.** The Worker already supplies independent PDF bytes. Writing them to disk, authorizing another file read, and maintaining leases adds file ownership without a current consumer. Source authorization and output limits apply directly to the memory result.
+
+**Automate installed Microsoft Office.** Word for Mac requires GUI automation rather than headless conversion, introducing focus changes and per-file operating-system authorization. Native Office automation remains outside this preview capability.
+
+**Use an online converter or download the engine automatically.** Remote conversion uploads document content. Automatic installation adds runtime distribution and update responsibilities; explicit artifact configuration leaves deployment with the operator.
+
+**Convert PDF pages to PNG.** PDF.js already owns PDF presentation. Rasterizing adds another image pipeline and loses the existing PDF controls and selectable text.
+
+**Add a model-facing rendering tool or durable preview events.** Client preview does not supply model input. A model-facing tool requires logged facts and both SDK projections, so it is a separate consumer.
+
+**Scan every font-table entry for warnings.** Font tables include unused styles and templates. Reporting them would make the notice unrelated to rendered content; collection instead starts after engine initialization and ends after PDF save.
+
+## Consequences
+
+The single-provider design assumes WASM covers the required Office rendering behavior; it is not evidence of complete fidelity equivalence with native LibreOffice. Fidelity depends on the engine build and installed fonts. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) cover DOCX, XLSX, PPTX, selectable Chinese text, and embedded system fonts; ABI fixtures alone cannot establish rendering fidelity or assembled browser presentation.
+
+WASM instances have substantial memory and startup costs, so the provider bounds concurrency. The [font reuse and image resolution decision](2026-09-11-wasm-preview-font-and-image-budgets.md) owns shared Host metadata, Worker request memoization, and raster export limits; only selected fonts enter engine memory. Large collections require bounded memory growth, and CFF export requires checked stack space. A fatal abort prevents further calls into C++. A device without a font covering a character cannot render it correctly without additional fonts. Owning the WASM build adds compiler compatibility, patch maintenance, and redistribution obligations.
+
+Prewarming consumes conversion resources to reduce foreground waits; entry and byte limits bound retained Client results. Source and PDF bytes remain outside Session storage and persistent caches. The [provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover independent output facts, input/output rejection, queued cancellation, and active Worker termination. Existing preview registry, file read authority, and Session ownership decisions remain active.
diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md
new file mode 100644
index 0000000000..a1ac8337a1
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md
@@ -0,0 +1,52 @@
+# Agent Note: 通过 WASM PDF 字节进行本地 Office 预览
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-10-local-office-preview.md) | 中文
+
+## 问题
+
+Office Open XML 文件是 ZIP 归档,因此文本回退无法提供有用的预览。文档转换必须留在本地,不抢占焦点,也不将文档内容放入模型对话。仅凭引擎成功状态无法证明输入具有声明的格式或生成了 PDF。
+
+## 决定
+
+[document-render Service Definition](../../../../packages/document/document-render/README.zh.md)、LibreOffice WASM 提供方及 Client/API 消费方构成转换能力。[启动组合](../../../../packages/document/document-render-auto/README.zh.md)仅在配置 `wasm.artifactDirectory` 时挂载提供方和 Remote 控制器。无效的显式配置会使初始化失败。Office Client 注册始终存在,为未配置的 Host 提供指引。可用性由生成的 Remote 命名空间决定,不发现可执行文件,也不维护重复的 Client 标记。
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)使用固定的官方 LibreOffice 源码、Emscripten 和 LibreOfficeKit 适配器。每个 Node Worker 拥有一个引擎实例、其 pthread 和内存文件系统。终止 Worker 可取消同步引擎调用;每次转换均在返回前等待 Worker 停止。OOXML 检查在启动前验证归档成员、内容类型和资源限制。
+
+VCL 回调在替换前请求字体族,或在字形回退时请求缺失的 Unicode 字符。Host 代码索引已安装字体元数据,将选中的文件复制到引擎内存,包括完整字体集合。精确匹配的已安装字体优先于配置的替代项;原字体优先的 fontconfig 别名在 LibreOffice 内保留此顺序。可选的初始字体族使用同一解析器。引擎不挂载 Host 文件系统,渲染不安装或下载字体。[构建配方](../../../../native/libreoffice-wasm/README.zh.md)记录源码与工具链版本、补丁及产物哈希。提供方使用期间,引擎包保持不可变;不同构建使用新目录。
+
+[独立引擎发布](../process/2026-09-11-independent-libreoffice-package.zh.md)负责预编译 npm 打包,并将引擎编译与普通 DSH 构建隔离。
+
+提供方返回调用方拥有的 PDF 字节,以及独立的 `succeeded`、`timedOut` 和 `cancelled` 事实。PDF 字节在清理前从引擎内存复制出来。API 消费方通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件,要求转换成功且未被中断,并直接编码 PDF。源路径和新鲜度版本保留在 PDF 传输中。Workspace Files 限制源文件读取;生成 PDF 仅受提供方的 `maxOutputBytes` 限制。无需临时 PDF 或文件租约。
+
+Office Client 观察所选 Conversation 现有的 Deliverables 投影,在不打开 tab 的情况下预转换最近的 Office 文件。有上限的内存缓存每次读取都检查已授权的源文件元数据,并在后台和前台调用方之间共享进行中的转换。仅最后一个退出的读取方取消共享转换;失败不缓存,连接重置丢弃缓存字节。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责格式选择、加载、取消和 PDF.js Worker。
+
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
+
+缺失字体族观察值随 PDF 经过 Worker 和 Remote 响应。Office 插件在共享 PDF 滚动区域上方呈现 keyed 提示,关闭时移除其布局高度。字体解析器取源文档及主题显式引用与实际文档请求的交集,排除引擎默认字体和字体表清单;已安装别名和字形回退本身不代表字体族缺失。
+
+## 考虑过的替代方案
+
+**保留已安装的原生 LibreOffice 提供方。** 第二个提供方增加可执行文件发现、版本查询、子进程监督、临时文件和平台特定隔离。本设计将 WASM 用于全部受支持的 Office 预览。引入原生提供方需要证明存在 WASM 无法满足的渲染或部署需求。
+
+**返回临时 PDF 路径。** Worker 已提供独立的 PDF 字节。将其写入磁盘、授权另一处文件读取并维护租约,会增加文件所有权,而没有当前消费方需要这些操作。源文件授权和输出限制直接应用于内存结果。
+
+**自动操作已安装的 Microsoft Office。** Word for Mac 需要 GUI 自动化而非 headless 转换,会引入焦点变化和逐文件操作系统授权。原生 Office 自动化不属于本预览能力。
+
+**使用在线转换器或自动下载引擎。** 远程转换会上传文档内容。自动安装增加运行时分发和更新责任;显式产物配置将部署留给运维者。
+
+**将 PDF 页面转换为 PNG。** PDF.js 已负责 PDF 展示。栅格化增加另一条图像流水线,并失去现有 PDF 控件和可选择文本。
+
+**添加面向模型的渲染工具或持久化预览事件。** Client 预览不提供模型输入。面向模型的工具需要记录事实并更新两种 SDK 投影,因此属于独立消费方。
+
+**扫描全部字体表条目生成提示。** 字体表包含未使用的样式和模板。报告这些条目会使提示与实际渲染内容无关,因此收集从引擎初始化完成后开始,在 PDF 保存后结束。
+
+## 影响
+
+单一提供方设计假设 WASM 覆盖所需的 Office 渲染行为;这不是与原生 LibreOffice 完全等价的保真度证据。保真度取决于引擎构建和已安装字体。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)覆盖 DOCX、XLSX、PPTX、可选择中文文本和嵌入系统字体;仅有 ABI fixture 无法证明渲染保真度或完整浏览器展示。
+
+WASM 实例有较高的内存与启动成本,因此提供方限制并发。[字体复用与图像分辨率决策](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)负责共享 Host 元数据、Worker 请求记忆化和栅格导出限制;仅选中字体进入引擎内存。大型集合需要有界内存增长,CFF 导出需要受检查的栈空间。致命 abort 后不再调用 C++。设备没有覆盖某字符的字体时,无法在不添加字体的前提下正确渲染该字符。自行构建 WASM 会增加编译器兼容、补丁维护和再分发义务。
+
+预转换消耗转换资源以减少前台等待;条目数和字节限制约束 Client 保留的结果。源文件和 PDF 字节不进入 Session 存储或持久缓存。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖独立输出事实、输入/输出拒绝、排队取消和活跃 Worker 终止。现有预览注册表、文件读权限和 Session 所有权决策保持有效。
diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml
new file mode 100644
index 0000000000..5e3c3d687d
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.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-11-wasm-preview-font-and-image-budgets.md
+2026-09-11-wasm-preview-font-and-image-budgets.md: ab33b0bb6e28e5749ac38f36c08bc7539a368515
+2026-09-11-wasm-preview-font-and-image-budgets.zh.md: e6ff458e8b68ce08ff5d81ce6767a08a82974a74
diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md
new file mode 100644
index 0000000000..ab33b0bb6e
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md
@@ -0,0 +1,36 @@
+# Agent Note: Font reuse and image resolution in WASM previews
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)
+
+## Problem
+
+Office conversion repeats font metadata parsing across documents and font matching within one document. Image-heavy documents also spend substantial PDF export time resampling images above preview resolution. These costs need separate controls because font reuse does not reduce image decoding or resampling.
+
+## Decision
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) builds a font metadata snapshot once during initialization. Each conversion Worker receives a structured clone. The Host retains names, face attributes, paths, sizes, and modification times; glyph coverage, request caches, and imported bytes belong to the Worker. Reloading the provider refreshes the snapshot. Workers validate indexed files when reading them, and an already imported font remains usable within that conversion.
+
+Each Worker memoizes complete requests: family, style, weight, italic, width, pitch, language, and ordered code points. The cache stores MEMFS paths and missing-family observations. Hits replay those observations because an initialization request can recur after document font collection starts. Worker termination releases both the cache and engine memory.
+
+Runtime PDF filter options reduce raster images to a configurable `maxImageResolution`, defaulting to 192 DPI. The [shared PDF canvas](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts) renders at 96 CSS DPI times the device pixel ratio; the default covers ratio 2. Text and vector graphics remain scalable. Explicit bookmark export preserves LibreOfficeKit's default when JSON filter options replace its implicit filter data.
+
+The [local Office preview decision](2026-09-10-local-office-preview.md) continues to own conversion lifetime, authorization, missing-font presentation, and PDF transport.
+
+## Alternatives considered
+
+**Index fonts inside every conversion Worker.** This keeps discovery off the Host event loop and sees newly installed fonts immediately, but repeats full-file metadata parsing across previews. A provider snapshot removes that repeated work at the cost of synchronous initialization and explicit reload after font changes.
+
+**Cache only the family name.** Style, language, pitch, and missing characters can select different files. A complete request key retains those distinctions and still captures repeated layout requests.
+
+**Keep 300 DPI for every preview.** Higher raster resolution retains detail for high pixel ratios and magnification but increases image export work. The configurable 192-DPI default follows the common display target without rasterizing text or rebuilding the engine.
+
+## Consequences
+
+Cold provider startup includes indexing and remains separate from conversion deadlines. Shared metadata retains no original font buffers, while each Worker still reads selected fonts and lazily decodes coverage. Installing or replacing fonts requires provider reload; a file changed since indexing can fail a later conversion.
+
+Reducing image resolution trades raster detail at high magnification for less export work. It is not an engine memory cap: LibreOffice can still decode full-resolution source images, load large font collections, or exhaust its build-time WASM memory maximum. No persistent font cache, filesystem watcher, or cross-document engine instance is introduced.
+
+[Font tests](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts) cover complete request keys, repeated missing-family observations, snapshot isolation, and failed imports. [Provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover snapshot reuse and reload. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) inspect exported image dimensions with default and overridden DPI settings alongside selectable text and page count; ABI forwarding alone cannot establish filter behavior.
diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md
new file mode 100644
index 0000000000..e6ff458e8b
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: WASM 预览的字体复用与图像分辨率
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-wasm-preview-font-and-image-budgets.md) | 中文
+
+## 问题
+
+Office 转换在不同文档间重复解析字体元数据,并在同一文档内重复匹配字体。图片较多的文档还会在 PDF 导出时花费大量时间,重采样高于预览显示分辨率的图像。这些成本需要分别控制,因为字体复用不会减少图像解码或重采样。
+
+## 决策
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)在初始化时建立一次字体元数据快照。每个转换 Worker 接收结构化克隆。Host 保留名称、字体面属性、路径、大小和修改时间;字形覆盖、请求缓存及导入字节属于 Worker。重新加载提供方会刷新快照。Worker 读取已索引文件时校验文件,已经导入的字体在该次转换内仍可使用。
+
+每个 Worker 记忆化完整请求:字体族、样式、字重、斜体、字宽、字距类型、语言及有序码点。缓存保存 MEMFS 路径和缺失字体族观察值。命中时重放这些观察值,因为初始化请求可能在文档字体收集开始后再次出现。Worker 终止时释放缓存及引擎内存。
+
+运行时 PDF 过滤器选项将栅格图像降低到可配置的 `maxImageResolution`,默认 192 DPI。[共享 PDF 画布](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts)按 96 CSS DPI 乘以设备像素比渲染;默认值覆盖像素比 2。文本和矢量图形仍可缩放。JSON 过滤器选项替换隐含过滤器数据时,显式导出书签保留 LibreOfficeKit 的默认行为。
+
+[本地 Office 预览决策](2026-09-10-local-office-preview.zh.md)继续负责转换生命周期、授权、缺失字体展示和 PDF 传输。
+
+## 考虑过的替代方案
+
+**在每个转换 Worker 内索引字体。** 这会将发现工作移出 Host 事件循环,并立即识别新安装的字体,但会在不同预览间重复读取完整字体文件并解析元数据。提供方快照消除重复工作,代价是同步初始化,以及字体变化后显式重新加载。
+
+**只缓存字体族名称。** 样式、语言、字距类型和缺失字符可能选择不同文件。完整请求键保留这些区别,同时仍能命中重复排版请求。
+
+**所有预览保持 300 DPI。** 更高栅格分辨率保留高像素比及放大时的细节,但增加图像导出工作。可配置的 192 DPI 默认值符合常见显示目标,无需栅格化文本或重新构建引擎。
+
+## 影响
+
+提供方冷启动包含索引成本,不计入转换时限。共享元数据不保留原始字体缓冲区,各 Worker 仍会读取选中字体并按需解码字形覆盖。安装或替换字体后需要重新加载提供方;索引后发生变化的文件可能使后续转换失败。
+
+降低图像分辨率以高倍缩放下的栅格细节换取更少的导出工作。它不是引擎内存上限:LibreOffice 仍可能解码全分辨率原图、加载大型字体集合,或耗尽构建时设定的 WASM 内存上限。此实现不引入持久化字体缓存、文件系统监听器或跨文档引擎实例。
+
+[字体测试](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts)覆盖完整请求键、重复缺失字体族观察值、快照隔离和失败导入。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖快照复用和重新加载。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)检查默认及覆盖 DPI 设置时的导出图像尺寸、可选择文本和页数;仅验证 ABI 参数转发无法证明过滤器行为。
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml
similarity index 56%
rename from .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml
rename to .agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml
index 9ae953b44a..4fac2496cb 100644
--- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml
+++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
-2026-08-03-web-sticky-collapsible-headers.md: 5bbe91681e656a8278b9d65f12e20a4d9dc779d0
-2026-08-03-web-sticky-collapsible-headers.zh.md: c75846fa7f5ca1088dab530b63243553e50f9579
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
+2026-09-09-content-sized-diagram-previews.md: fec86111a68ec0fbc12ed6514a60c6a243924963
+2026-09-09-content-sized-diagram-previews.zh.md: 8880f12853f9e7d2d566fca3a482fd5e17514526
diff --git a/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md
new file mode 100644
index 0000000000..fec86111a6
--- /dev/null
+++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md
@@ -0,0 +1,26 @@
+# Agent Note: Content-sized diagram previews
+
+Status: implemented
+Archived: 2026-09-10
+
+English | [中文](2026-09-09-content-sized-diagram-previews.zh.md)
+
+## Problem
+
+Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser.
+
+## Decision
+
+Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer.
+
+This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document.
+
+## Alternatives considered
+
+**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport.
+
+**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content.
+
+## Consequences
+
+Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements.
diff --git a/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md
new file mode 100644
index 0000000000..8880f12853
--- /dev/null
+++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md
@@ -0,0 +1,26 @@
+# Agent Note: 按内容定高的图表预览
+
+Status: implemented
+Archived: 2026-09-10
+
+[English](2026-09-09-content-sized-diagram-previews.md) | 中文
+
+## Problem
+
+较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。
+
+## Decision
+
+Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。
+
+这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。
+
+## Alternatives considered
+
+**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。
+
+**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。
+
+## Consequences
+
+较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
similarity index 67%
rename from .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
rename to .agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
index 79ba95b677..212dd5d306 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml
+++ b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.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-11-session-controller-fork-turn-cut.md
-2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
-2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db
+2026-09-11-session-controller-fork-turn-cut.md: 9223acd4c0c7bbcc84a0bfeb72f3b57d4bf828e2
+2026-09-11-session-controller-fork-turn-cut.zh.md: cae022ae97896c0b43633edb310afb305d690b4a
diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
similarity index 99%
rename from .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
rename to .agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
index 0c8d45c861..9223acd4c0 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
+++ b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
@@ -1,6 +1,7 @@
# Agent Note: Session Controller forks stop at the selected turn end
Status: implemented
+Archived: 2026-09-16
English | [中文](2026-09-11-session-controller-fork-turn-cut.zh.md)
diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
similarity index 96%
rename from .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
rename to .agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
index 729a42a596..cae022ae97 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
+++ b/.agents/notes/archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md
@@ -1,8 +1,9 @@
# Agent Note: Session Controller 分叉截到选中轮次的结束事件
Status: implemented
+Archived: 2026-09-16
-English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
+[English](2026-09-11-session-controller-fork-turn-cut.md) | 中文
## Problem
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index 0d758eec58..88573f7db2 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -364,6 +364,15 @@
"architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
"architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
"architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+ "architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00",
+ "architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290",
+ "architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7",
+ "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8",
+ "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763",
+ "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53",
+ "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
+ "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
+ "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
@@ -739,6 +748,12 @@
"bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
"bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
"bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
+ "bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
+ "bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
+ "bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
+ "bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
+ "bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
+ "bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
@@ -1609,6 +1624,9 @@
"process/2026-09-03-workspace-version-coherence-gate.i18n.yaml": "sha256:ac442dee172d396395b8516d6d38c4fcedbc855d735c86c378aec5c1098ecd8d",
"process/2026-09-03-workspace-version-coherence-gate.md": "sha256:37b63a78506a8741eb04826dc1eea7e3a64c19fd4494c2d7754054a5602d308b",
"process/2026-09-03-workspace-version-coherence-gate.zh.md": "sha256:cfa2c8fe6cf1d4665121a987861b70a91b5b41eb99e7ed192c85760a916fddf6",
+ "process/2026-09-11-independent-libreoffice-package.i18n.yaml": "sha256:e8d2fe75e05ac53438513a7c51dab6510bcecf644019391ee6650e73961c0f8d",
+ "process/2026-09-11-independent-libreoffice-package.md": "sha256:1292b409b7b4cbc4420868de5e3b7417b3c855889023ccfd9a61d824bf3019d8",
+ "process/2026-09-11-independent-libreoffice-package.zh.md": "sha256:384783da1614c7e2ea84ea513013af6b20e9d4545a03cf7fa068415078126932",
"simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml": "sha256:43fe5daadc1491f94a3e34595182cad1591f76b64e1f13b48811b80fd7e53b94",
"simplification/2026-06-19-drop-mutable-session-summary.md": "sha256:01647a5a14aa4e195328d4d39c6d80eb0723739e5a543115314716b280a93560",
"simplification/2026-06-19-drop-mutable-session-summary.zh.md": "sha256:22389e0c29158b1f7a5a43ed6073f8795bb87cf51c04cf83be055359cd65c9f5",
diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml
similarity index 55%
rename from .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml
rename to .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml
index c971fb1bfd..7f17afb0a9 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml
+++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml
@@ -1,6 +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-09-composer-placeholder-whitespace.md
-2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e
-2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-independent-libreoffice-package.md
+2026-09-11-independent-libreoffice-package.md: 05fd8eb86ffb75b564008eaffda5c98669afee27
+2026-09-11-independent-libreoffice-package.zh.md: b2abf61f8a5cf9b247fa2b94697eb90a252d4cff
diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md
new file mode 100644
index 0000000000..05fd8eb86f
--- /dev/null
+++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md
@@ -0,0 +1,32 @@
+# Agent Note: Independent precompiled LibreOffice package
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-independent-libreoffice-package.zh.md)
+
+## Problem
+
+Compiling LibreOffice inside ordinary DSH builds would require every contributor and CI job to acquire its toolchain and repeat a large native build. Desktop also needs immutable engine resources that can travel with its offline installation material.
+
+## Decision
+
+The [engine package](../../../../native/libreoffice-wasm/package.json) has its own version and an asset-only manifest export. Its source directory stays outside the main pnpm workspace. Installation has no lifecycle hook, and packaging verifies an existing successful build without compiling. The [release workflow](../../../../.github/workflows/libreoffice-wasm-release.yml) compiles only on explicit dispatch; publication requires a matching engine version tag and the protected npm environment.
+
+Each tarball contains the engine manifest and assets, corresponding source pins, patches, full source diff, and the built distribution's license and notices. Packaging rejects mismatched build receipts, modified assets, unlisted engine files, missing notices, and bundled fonts. A failure never triggers compilation automatically.
+
+The [local preview decision](../architecture/2026-09-10-local-office-preview.md) continues to own conversion and explicit artifact configuration. This change establishes release machinery; it does not add an unpublished engine dependency to DSH. The existing [Desktop package set](../../../../apps/desktop/scripts/prepare-package-set.ts) remains the intended carrier for a future fixed-version dependency and its offline seed.
+
+## Alternatives considered
+
+**Compile during ordinary builds or package installation.** This puts engine toolchain availability and compilation cost on every consumer, even when the engine version has not changed.
+
+**Extract a general Node conversion library.** Font handling, Worker ownership, and the conversion API can remain with the provider. Moving them is unnecessary to distribute precompiled assets or isolate compilation from DSH CI.
+
+**Link the engine source as a workspace dependency.** Workspace linking would replace a published precompiled package with a source directory that lacks its engine files.
+
+## Consequences
+
+The engine can be compiled and versioned independently of DSH. The package exposes files rather than a conversion API; the provider retains runtime ownership. Ordinary builds remain free of LibreOffice compilation, as checked by the [packaging tests](../../../../scripts/libreoffice-package.spec.ts).
+
+Initial publication and fixed-version consumer integration remain incomplete. A source tree that differs from its recorded recipe cannot produce a release tarball. Real-engine conversion, installation from the packed package, and Desktop offline seed qualification must accompany the first consumed engine release.
diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md
new file mode 100644
index 0000000000..b2abf61f8a
--- /dev/null
+++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md
@@ -0,0 +1,32 @@
+# Agent Note: 独立的预编译 LibreOffice 包
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-independent-libreoffice-package.md) | 中文
+
+## 问题
+
+在普通 DSH 构建中编译 LibreOffice,会要求每个贡献者和 CI job 获取工具链并重复执行大型本机构建。Desktop 也需要能随离线安装材料分发的不可变引擎资源。
+
+## 决策
+
+[引擎包](../../../../native/libreoffice-wasm/package.json)采用独立版本,只导出产物清单。源码目录不属于主 pnpm workspace。安装没有生命周期 hook,打包只校验已有的成功构建,不执行编译。[发布工作流](../../../../.github/workflows/libreoffice-wasm-release.yml)仅在明确调度时编译;发布要求匹配的引擎版本 tag 和受保护的 npm 环境。
+
+每个 tarball 包含引擎清单和产物、对应的源码版本、补丁、完整源码差异,以及构建发行物的许可证和声明。打包拒绝不匹配的构建记录、被修改的产物、未列入清单的引擎文件、缺失的声明和内置字体。失败不会自动触发编译。
+
+[本地预览决策](../architecture/2026-09-10-local-office-preview.zh.md)继续负责转换与显式产物配置。此改动建立发布机制,不向 DSH 添加尚未发布的引擎依赖。现有 [Desktop 包集合](../../../../apps/desktop/scripts/prepare-package-set.ts)仍是未来固定版本依赖及其离线 seed 的预期载体。
+
+## 考虑过的替代方案
+
+**在普通构建或包安装期间编译。** 即使引擎版本没有变化,每个消费方也要承担工具链可用性要求和编译开销。
+
+**抽出通用 Node 转换库。** 字体处理、Worker 所有权和转换 API 可以继续由提供方负责。分发预编译产物及将编译与 DSH CI 隔离不需要迁移这些逻辑。
+
+**将引擎源码链接为 workspace 依赖。** 工作区链接会把已发布的预编译包替换为缺少引擎文件的源码目录。
+
+## 影响
+
+引擎可以独立于 DSH 编译和发布版本。包暴露文件而非转换 API;提供方保留运行时所有权。[打包测试](../../../../scripts/libreoffice-package.spec.ts)检查普通构建不编译 LibreOffice。
+
+首次发布和固定版本消费方接入尚未完成。与记录的配方不一致的源码树不能产生发布 tarball。首个被消费的引擎发行版必须同时完成真实引擎转换、打包安装,以及 Desktop 离线 seed 验证。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
index 98850aed4c..b62c09f26c 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
-2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2
-2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b
+2026-06-11-content-block-vocabulary.md: 917dd5225fdc4ab391e9e4629769abbb68095731
+2026-06-11-content-block-vocabulary.zh.md: 9d228eb34dafbdebab44b45dc3b290c9327d3b2b
diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
index d7d3f6b43a..917dd5225f 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
@@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log,
## Decision
-Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
+Own the vocabulary: user, assistant, and system messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. Tool results are first-class tool-role messages whose own content holds result blocks; released Session format v3 retains the historical `tool-result` content block inside a user-role wrapper, and the adjacent V3-to-V4 edge lifts that wrapper into the first-class role. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
In-session context injection (`context/message`) and mid-turn steering originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role.
diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
index ed4f915dff..9d228eb34d 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
@@ -10,7 +10,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
## 决策
-自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
+自主拥有词汇:user、assistant 与 system 消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。Tool result 是一等 tool-role message,结果块属于该 message 自己的 content;已发布的 Session format v3 仍将历史 `tool-result` content block 保存在 user-role 包装中,邻接的 V3 到 V4 边会将该包装提升为一等角色。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml
index 98f54d5d26..081f618b1a 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.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-20-routed-model-context-and-compaction-policy.md
-2026-07-20-routed-model-context-and-compaction-policy.md: 26d8a4a85c284e50b9635017f0dd0bf905a4427f
-2026-07-20-routed-model-context-and-compaction-policy.zh.md: 8656200a4b25c2ca70160e8f5949a7518e390168
+2026-07-20-routed-model-context-and-compaction-policy.md: d276354cb21f45eaec1c4e3202c7be1770745efb
+2026-07-20-routed-model-context-and-compaction-policy.zh.md: b6e2a0b914f78e19675d80b86d8d962e8ba8c80f
diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md
index 26d8a4a85c..d276354cb2 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md
@@ -16,7 +16,7 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and
`LlmAdapter.resolveModel(provider, model, signal?)` returns aggregate metadata for one exact route, with optional `LlmModelContext` under its `context` field. `LlmRuntime.resolveModelInfo()` selects the registered route owner, validates a positive integer `contextWindow`, and returns detached metadata. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and an absent `context` means only that the adapter cannot describe capacity.
-The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The two built-in model entries each publish an exact 256,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
+The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The built-in model entries each declare a 1,000,000-token context capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
### Token measurement remains model-agnostic
@@ -26,17 +26,17 @@ The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each config
Compact-basic owns consumer policy. Top-level fields define defaults; `modelPolicies` contains partial overrides keyed by the exact `{ provider, model }` pair. Duplicate targets and unknown or invalid fields fail plugin load. `thresholdRatio` defaults to `0.8`, and retention defaults to `retainRatio: 0.16`; callers may use an absolute `retainTokens` instead, but the two retention forms are mutually exclusive. After inheritance, a ratio retention that is not below its threshold ratio also fails plugin load because no model capacity can make that policy valid.
-For proactive pressure, compaction-basic reads the latest durable request route, resolves its adapter capacity and exact-target policy, and scales ratios into a `ResolvedCompactSpec`. It performs this resolution on every check, so a provider or model switch in one session changes capacity and policy immediately. An absolute retained budget that is not below the scaled threshold fails when the target capacity first makes that comparison possible.
+For proactive pressure, compaction-basic reads the latest durable route and resolves its adapter capacity `W`, request output reservation `O`, and exact-target policy. The trigger is `floor(min(W × thresholdRatio, W − O − headroomTokens))`, with default headroom of 65,536 tokens. The window fraction limits history growth while the second cap leaves extra room after a full output for compaction input and generation; it does not bound arbitrary tool-result growth. Retention scales `W − O` independently. `O` is the effective envelope’s `maxTokens`, else the adapter’s per-request default, else zero. Resolution runs on every check, so route or output-cap changes immediately affect policy. A nonpositive pressure budget or retention at least as large as the trigger fails when target capacity makes that comparison possible.
The same exact-target override can select summarization provider/model, summarization output cap, convergence retries, and overflow retry cap. These are compaction concerns and never enter an LLM provider.
### Target-specific pressure failures preserve optional composition
-An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same per-route suppression applies when resolved capacity exposes an invalid absolute retention budget, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress.
+An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same suppression applies when output plus headroom exhausts capacity or retention reaches the trigger, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress.
## Testing
-Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek exact/default/unlisted resolution, invalid capacities, and pi-ai exact descriptor resolution. Compact tests cover ratio scaling, exact provider/model overrides, load-time rejection of invalid merged ratios, runtime absolute-budget validation, same-model-id provider switches, target-specific warning suppression, and capacity-independent overflow recovery. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters.
+Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek exact/default/unlisted resolution, invalid capacities, and pi-ai exact descriptor resolution. Compact tests cover both trigger caps, default and overridden headroom, envelope output caps and adapter fallback, independent retention scaling, invalid budgets, exact-target overrides, provider switches, warning suppression, and capacity-independent overflow recovery. The keyless `compaction-output-reserve` headless scenario records a nonzero request cap, proactive compaction before the next step, and replacement history. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters.
## Alternatives considered
@@ -52,6 +52,6 @@ Service tests cover detached context metadata, invalid adapter output, catalog i
- The same compaction-basic instance safely handles different windows, provider switches, and identical model ids under different providers without consulting discovery metadata.
- LLM-only and meter-only compositions remain valid; loading compaction-basic adds no reverse dependency from adapters.
- DeepSeek deployments may set exact per-model capacities, or use `defaultContextWindow` for entries without capacity and unlisted pass-through ids.
-- Ratio defaults scale naturally across models, while exact-target absolute retention remains available for deployment-specific behavior.
+- Ratio defaults scale naturally across models and exclude the output tokens each request reserves, while exact-target absolute retention remains available for deployment-specific behavior.
This note supersedes the global-capacity and no-model-policy parts of the [replay token meter service Agent Note](../../archived/architecture/2026-07-15-replay-token-meter-service.md). Its single-fold measurement decision remains unchanged.
diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md
index 8656200a4b..b6e2a0b914 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md
@@ -16,7 +16,7 @@ Status: implemented
`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmRuntime.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回与适配器内部状态分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。
-手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 256,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
+手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。内置模型项各自声明 1,000,000 token 的上下文容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
### Token 计量保持模型无关
@@ -26,17 +26,17 @@ Status: implemented
Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolicies` 包含以精确 `{ provider, model }` 组合为键的部分覆盖。重复目标、未知字段或无效字段都会让插件加载失败。`thresholdRatio` 默认为 `0.8`,保留策略默认为 `retainRatio: 0.16`;调用方也可以改用绝对 `retainTokens`,但两种保留形式互斥。完成继承后,如果保留比例不小于阈值比例,插件也会加载失败,因为任何模型容量都无法让该策略有效。
-对于主动压力检查,compaction-basic 读取最新持久请求路由,解析其适配器容量与精确目标策略,再把比例缩放为 `ResolvedCompactSpec`。每次检查都会重新解析,因此同一会话切换提供方或模型后,容量与策略会立即变化。若绝对保留预算不小于缩放后的阈值,系统会在目标容量首次允许比较两者时失败。
+对于主动压力检查,compaction-basic 读取最新持久路由,解析适配器容量 `W`、请求输出预留 `O` 和精确目标策略。触发阈值为 `floor(min(W × thresholdRatio, W − O − headroomTokens))`,默认余量为 65,536 tokens。窗口比例限制历史增长,第二项则在一次完整输出后为压缩输入与生成留出额外空间;它无法限制任意工具结果的增长。保留预算独立按 `W − O` 缩放。`O` 取自生效信封的 `maxTokens`,否则取适配器按请求的默认值,再回退到零。每次检查都会重新解析,因此路由或输出上限变化会立即影响策略。若压力预算非正,或保留预算不低于触发阈值,会在目标容量允许比较时失败。
同一精确目标覆盖还可以选择摘要提供方/模型、摘要输出上限、收敛重试次数与溢出重试上限。这些都属于压缩问题,不会进入任何 LLM 提供方。
### 目标专用压力错误仍保留可选组合
-缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大且平衡的缩减,并在替换无法证明进展时保留原始提供方错误。
+缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当输出预留与余量耗尽容量,或保留预算达到触发阈值时,也会抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大且平衡的缩减,并在替换无法证明进展时保留原始提供方错误。
## 测试
-服务测试覆盖与适配器内部状态分离的上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture(测试前置数据)会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。
+服务测试覆盖与适配器内部状态分离的上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖两项阈值上限、默认和覆盖余量、信封输出上限与适配器回退、独立保留预算缩放、无效预算、精确目标覆盖、提供方切换、警告抑制和不依赖容量的溢出恢复。无需密钥的 `compaction-output-reserve` headless 场景记录非零请求上限、下一步之前的主动压缩和替换后的历史。Loader fixture(测试前置数据)会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。
## 考虑过的替代方案
@@ -52,6 +52,6 @@ Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolici
- 同一个 compaction-basic 实例无需查询发现元数据,就能安全处理不同窗口、提供方切换,以及不同提供方下的相同模型 id。
- 仅 LLM 与仅 meter 的组合仍然有效;加载 compaction-basic 不会让适配器产生反向依赖。
- DeepSeek 部署可以设置精确的逐模型容量,也可以让未提供容量的模型项与未列出的透传 id 使用 `defaultContextWindow`。
-- 比例默认值会随模型自然缩放,同时仍可按精确目标使用绝对保留值,以满足部署专用行为。
+- 比例默认值会随模型自然缩放,并扣除每次请求预留的输出 token;同时仍可按精确目标使用绝对保留值,以满足部署专用行为。
本记录取代[回放式 token 计量服务 Agent Note](../../archived/architecture/2026-07-15-replay-token-meter-service.md) 中的全局容量与无模型策略部分,单折叠计量决策保持不变。
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
index a7b0ac458f..e891710dea 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 6126d06e48111a551a0dc57247659a3086986c66
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb
+2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
index 6126d06e48..990a9b6eef 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
@@ -63,21 +63,21 @@ Session instances share the scope's lifecycle, while the catalog reports discove
A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
-- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, so blank never touches disk.
+- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md).
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally.
-- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
+- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup.
### connectWorkspace: the sole entry point of New Session
`workspaces.connectWorkspace(workspaceId): Promise` (owned by WorkspaceRuntime — it holds both the workspace canonical path and the sessions reference):
-- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
-- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
+- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit is adopted with explicit-id `session.create`, which acquires and retains the writer before returning. Only writer contention skips a candidate.
+- The create arm: when no candidate can be acquired, `session.create({workspaceId})` returns the new id.
- An unknown workspaceId fails loud (never silently creating somewhere else).
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush.
- The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
@@ -135,6 +135,6 @@ Blank Sessions retain the header's leading and corner slots so navigation contro
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
-- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse.
+- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse.
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval.
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built).
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
index e7bf4aa695..5c90611fab 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
@@ -63,21 +63,21 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持
「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
-- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。
+- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。
-- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
+- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。
### connectWorkspace:New Session 的唯一入口
`workspaces.connectWorkspace(workspaceId): Promise`(归属 WorkspaceRuntime——它同时持有 workspace 规范 path 与 sessions 引用):
-- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。
-- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
+- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中后通过显式 ID 的 `session.create` 收养,在返回前取得并持续持有写锁。仅写锁争用会跳过候选。
+- 新建臂:没有可获取的候选时,`session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud(不静默创建到别处)。
- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。
- 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。
@@ -135,6 +135,6 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入
- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。
-- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。
+- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。
diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml
index 1b91468214..56d5cea939 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.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-28-identified-immutable-message-values.md
-2026-07-28-identified-immutable-message-values.md: 9d2b22a5a1fc87ea7b0ff8eb24b2e8c4b86543e1
-2026-07-28-identified-immutable-message-values.zh.md: 6b36cfec0a24196ca526b0ffb21180967aa640eb
+2026-07-28-identified-immutable-message-values.md: 73c9c14479117cf654d10b3da23d155cca77054d
+2026-07-28-identified-immutable-message-values.zh.md: 7a5b9d6524beff01ac8a9e2020f75093271b7ab3
diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md
index 9d2b22a5a1..73c9c14479 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md
+++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md
@@ -16,7 +16,7 @@ This made identity a routing side effect rather than a message invariant. Produc
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content plus provider, model, and optional replay state. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
-The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
+The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to a tool-role message carrying the result content, error state, and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
diff --git a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md
index 6b36cfec0a..7a5b9d6524 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md
@@ -16,7 +16,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容,以及提供方、模型和可选的回放状态。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。
-消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
+消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与携带结果内容、错误状态及来源的 tool-role 消息耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml
index f5d261079d..cabffaebf6 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md
-2026-07-29-dsh-source-launch-tsx-esm.md: 5401b0257faa69eaf76b11e7b51d06afd7ec2b8c
-2026-07-29-dsh-source-launch-tsx-esm.zh.md: f32fa3ece91bf7b9829d7012ceb090cabaa833a9
+2026-07-29-dsh-source-launch-tsx-esm.md: 700577cb9b137d67f20baeb639f99f4c0877011d
+2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d322d3dcf49442ee62f82203f17bd2ab16467fe
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
index 5401b0257f..700577cb9b 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
@@ -14,11 +14,13 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s
## Decision
-The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script uses that vector directly from the repository root; artifact generation is a separate operation under the [source-launch/build separation decision](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md). The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
+The `dsh` CLI source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script uses that vector directly from the repository root; artifact generation is a separate operation under the [source-launch/build separation decision](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md). The CJS hook stays off because the CLI source graph is ESM-only; the implementation-time measurement to the TUI banner was ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
-`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-jobs` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.)
+tsx owns workspace `paths` mapping without checking whether the importer declares each package as a runtime dependency. Declaration completeness rests on the static gates: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. The removed repo-owned paths loaders enforced those declarations at runtime and caught imports of `@deepseek-ai/dsh-llm` declared only in devDependencies; tsx does not provide that check.
-The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production runtime vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
+tsx skips the paths map when the importer URL contains `/node_modules/`. The [runtime resolution](2026-09-09-profile-resolution-generations.md) therefore records real declaring-package anchors, including recursive dependencies. This lets workspace fallback imports resolve to `src/` consistently, rather than mixing built `lib/` providers with source callers whose module-local Symbols differ. Packages without a workspace mapping retain ordinary export resolution.
+
+The node-compat CI matrix runs `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`) without a build: keyless launches assert the required-profile diagnostic and profile dependency resolution without mixed Tools/AgentLoop `src/` and `lib/` instances. The source and built-bin suites share ordinary-directory and npm-link profile cases; the latter launches plain Node against built exports. The required build-backed smoke also runs `apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts`: it checks real headless tool dispatch through tsx after the native addon and generated typert contributors are available, while still requiring core workspace modules to load from source. Future changes to module hooks or TypeScript handling are checked through these real entry paths.
## Alternatives considered
@@ -35,4 +37,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
-- Runtime launch improves ~0.4s over the full tsx default; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path.
+- Every CLI source profile uses the same ESM-only hook, including ACP. Workspace runtime entries reached by this launch must expose ESM exports; the launcher does not install a CJS TypeScript transform hook.
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
index f32fa3ece9..4d322d3dcf 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
@@ -14,11 +14,13 @@ Status: implemented
## 决策
-`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本直接从仓库根目录使用同一启动方式;产物生成是独立操作,由[源码启动与构建分离决策](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md)规定。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
+`dsh` CLI(命令行界面)源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本直接从仓库根目录使用同一启动方式;产物生成是独立操作,由[源码启动与构建分离决策](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md)规定。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实现时测得启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
-`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-jobs` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。)
+tsx 负责 workspace `paths` 映射,不检查导入方是否把每个包声明为运行时依赖。声明完整性由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。已移除的仓库自有 paths loader 曾在运行时强制这些声明,并发现过将 `@deepseek-ai/dsh-llm` 仅声明在 devDependencies 中的 import;tsx 不提供这项检查。
-node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产运行时启动向量做 keyless 管道 stdio 启动,断言进程会因 TTY 拒绝而以非零状态退出。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
+导入方 URL 含有 `/node_modules/` 时,tsx 会跳过 paths 映射。因此,[runtime resolution](2026-09-09-profile-resolution-generations.zh.md)记录声明包的真实锚点,也覆盖递归依赖。这让 workspace fallback import 一致解析到 `src/`,避免混用构建后的 `lib/` 提供方与模块本地 Symbol 不同的源码调用方。没有 workspace 映射的包仍使用普通导出解析。
+
+node-compat CI 矩阵在不执行 build 的情况下运行 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):无密钥启动断言必需 profile 的诊断,以及不混用 Tools/AgentLoop `src/` 与 `lib/` 实例的 profile 依赖解析。源码与构建入口测试共享普通目录和 npm-link profile 用例;后者通过普通 Node 加载构建后的导出。必跑的 build-backed smoke 还运行 `apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts`:在 native addon 和生成的 typert 贡献文件就绪后,通过 tsx 检查真实 headless 工具派发,同时仍要求核心 workspace 模块从源码加载。模块钩子或 TypeScript 处理的后续变化由这些真实入口检查。
## 备选方案
@@ -35,4 +37,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
-- 运行时启动相比完整 tsx 默认形态快约 0.4s;ACP(Agent Client Protocol)保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上。
+- 每个 CLI 源码 profile 都使用相同的 ESM-only 钩子,包括 ACP(Agent Client Protocol)。该启动方式访问的 workspace 运行时入口必须提供 ESM 导出;启动器不安装 CJS TypeScript 转换钩子。
diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
index 4ebcbc33d9..c58f37c2b3 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
-2026-08-05-profile-plugin-bundles.md: 48786a9c1ccaceb5f16c9eefed01e556cf759e6d
-2026-08-05-profile-plugin-bundles.zh.md: c88cbf98e4276549a6fae6a5b1253d3d838a64ca
+2026-08-05-profile-plugin-bundles.md: 0607f1c9a2414cc16515967b4520da8a325853fd
+2026-08-05-profile-plugin-bundles.zh.md: 9d25d3bea6337c472d161c721e44a66dca8470c9
diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
index 48786a9c1c..0607f1c9a2 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
@@ -14,14 +14,14 @@ Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile ` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile ` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
-Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md), which applies the same installation-first and ordered-bundle rules in memory; retained link and dual modes can materialize the same result.
+Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md), which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-host-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../../archived/feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing.
## Alternatives considered
- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan.
-- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
+- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. Two-anchor resolution plus runtime fallback keeps bundles installation-owned without persisting those links.
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
- **Dynamic template inheritance or cloning a local profile**: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
index c88cbf98e4..9d25d3bea6 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
@@ -14,14 +14,14 @@ Status: implemented
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile `,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
-解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md),在内存中应用相同的安装优先与有序 bundle 规则;保留的 link 与 dual 模式可以物化同一结果。
+解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md),在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-host-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../../archived/feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与响亮失败的解析。
## Alternatives considered
- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。
-- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
+- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加运行时 fallback 可以让组合包保持由安装持有,无需持久化这些链接。
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(`ctx.cmdlineArgs`、`ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
- **动态模板继承或克隆本地 profile**:记录父级会要求为 bundle 成员关系、依赖和用户 patch 制定合并与升级规则,而复制本地状态会重复机器特定选择。基于模板的创建只会一次性复制安装自有的默认值。
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
index aad1a96be8..bb7be001de 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 43f9f0359822f7d7c5b52c09646fd3ffbf80f783
-2026-08-09-client-conversation-node-assembly.zh.md: d7475f5ff8218419743448d455d5d5ffe3cc4091
+2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521
+2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
index 43f9f03598..bb1d68cc04 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
@@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it.
-Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)).
+Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. Resume headers retain a Chat prompt card even when the system text is unchanged. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor can hide an unchanged `change` header without withdrawing its stable Node key; resume and explicit series-start cards remain visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts)).
Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
index d7475f5ff8..6291211d58 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
@@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。
-Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。
+Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。即使 system 文本未变化,resume header 仍保留 Chat prompt 卡片。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 `change` header 可以隐藏,但不撤回其稳定 Node key;resume 和显式序列起点的卡片仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts))。
Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
index 94db853913..9a020189ec 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 513b126d3b00841f71893715cc296cb67619543e
-2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0
+2026-08-10-session-log-version-mechanism.md: 9cb0940f767aa27d5a5106fb78b528c1e3e2695b
+2026-08-10-session-log-version-mechanism.zh.md: e1424f24e5dd74b243fe1f0aebceb5d420b9b8c3
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
index 513b126d3b..9cb0940f76 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
@@ -18,6 +18,8 @@ Session logs must be upgradable after release, and the runtime that ships first
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
+The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) prevents an accepted transition from reusing its header increase for later breaking changes. Backward-compatible evolution uses new acknowledgement records at the same Session version; breaking changes require a higher writer. The checkpoint does not change the unknown-event protocol.
+
### Writer and publication authority
`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers.
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
index 919bfb077c..e1424f24e5 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
@@ -18,6 +18,8 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
+[已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)阻止已接受转换复用原来的头版本递增来授权后续破坏性变更。向后兼容的演进在相同 Session 版本上使用新的确认记录;破坏性变更要求更高的写入器版本。检查点不改变未知事件协议。
+
### 写入器与发布真源
`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。
diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml
index 0ac7d7109d..8a7e75ad27 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
-2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9
-2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec
+2026-08-18-experimental-agent-teams-packages.md: 92eba148b328135301b196c9a17e49ee23ddb3f6
+2026-08-18-experimental-agent-teams-packages.zh.md: 6f4d004a78e32e2c27ad65648c446b3cd54c07ad
diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
index 4a78a60c2a..92eba148b3 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
+++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
@@ -12,17 +12,17 @@ Moving the packages into product-role groups would remove their experimental nam
## Decision
-`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion.
+`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, and `packages/experimental/client-ui-agent-team` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion.
-The dsh pack and publish set and the local baseline publisher include these five Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other.
+The dsh pack and publish set and the local baseline publisher include these four Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other.
The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction.
-The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
+The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation.
-Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
+Profile startup resolves selected bundles before computing the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md). The runtime resolution retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. The runtime interception enforces the result in memory without materializing fallback links. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
-Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
+Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
## Alternatives considered
@@ -36,6 +36,6 @@ Experimental status changes compatibility and support expectations, not publicat
## Consequences
-Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them.
+Agent Teams publishes as four installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them.
The release family carries the experimental npm names. Promotion still creates path and npm-name churn as specified by the experimental package rules.
diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md
index 8056cd8194..6f4d004a78 100644
--- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md
@@ -12,17 +12,17 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess
## 决策
-`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。
+`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile` 与 `packages/experimental/client-ui-agent-team` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。
-dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。
+dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。
通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。
-公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。
+[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。
-profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
+profile 启动会先解析所选 bundle,再计算[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md)。runtime resolution 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。运行时拦截在内存中强制该结果,不物化 fallback 链接。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
-对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
+对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
## 曾考虑的替代方案
@@ -36,6 +36,6 @@ profile 启动会先解析所选 bundle,再计算[不可变 profile resolution
## 后果
-Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。
+Agent Teams 会作为 dsh 发布系列中的四个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。
发布系列保留实验性 npm 名称。promotion 仍会按照实验性包规则产生路径和 npm 名改动。
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
index fff8e98bc5..dcadb91f94 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28
-2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9
+2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c
+2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
index 703960cb24..931276fa6d 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
@@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner
## Decision
-The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain.
+The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain.
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
-| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) |
+| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) |
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
## Terminology
@@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`.
-**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row.
+**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row.
**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls.
@@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
| Input | Preserved result |
|---|---|
| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
-| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot |
+| successful standard foreground call | terminal output, exit code/signal, and settled state presentation |
| `run_in_background:true` | Generic row and raw result |
| tool execution error | Generic IN/OUT and error summary |
| running persistent `bash`/`pwsh` | terminal prompt |
diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
index 7f16137955..b930f98da1 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
@@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
## Decision
-下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。
+下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
-| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 |
+| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 |
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
## 术语
@@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。
-**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。
+**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。
**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。
@@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
| 输入 | 保持的结果 |
|---|---|
| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
-| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 |
+| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 |
| `run_in_background:true` | Generic 行与原始结果 |
| 工具执行 error | Generic IN/OUT 与错误摘要 |
| persistent `bash`/`pwsh` running | terminal prompt |
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
index 88e2561815..af52bdcb23 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-24-browser-token-authentication.md
-2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4
-2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f
+2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a
+2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
index cdfed1f250..47d6afe1bf 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
@@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
-Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
+Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
-The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling.
+The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
index 29f3a6c0a2..13ecf55e41 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
@@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
-每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
+每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
-cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。
+cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
index eba3a6c4a8..6fa3c9e31b 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8
+2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763
diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
index c532db9c30..f58e8dc471 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
-Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
@@ -24,13 +24,13 @@ Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runt
One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged.
-The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
+The Desktop Host exposes the shared Web plugin manager for its reserved profile and supplies bundled pnpm through launcher facts. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before profile recovery or Host startup; later launches focus or recreate the primary window without touching profile state.
## Ownership
| Owner | Responsibility |
|---|---|
-| Electron shell | Window and child lifecycle, local shell pages, reserved desktop profile, plugin GUI, update coordination |
+| Electron shell | Window and child lifecycle, reserved desktop profile preparation, native recovery, update coordination |
| Electron RunAsNode and pnpm | Execute dsh and install desktop-project dependencies using pnpm’s normal configuration |
| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision |
| Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
@@ -38,7 +38,9 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
| Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions |
| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state |
-The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy.
+The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload provides boot readiness and failure reporting, native directory selection, theme synchronization, and Windows menu and appearance adapters. It exposes no raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron menus and native dialogs use typed English or Chinese copy with English fallback; Windows follows the main document’s language. The shared Web plugin manager owns its client copy.
+
+Update confirmations use the shell static origin because they must remain available without a ready Web Host. Their packaged documents and assets retain the same method and path restrictions as other local static assets.
## Filesystem layout
@@ -59,9 +61,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
## Installation and resolution
-Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md).
+The shared Web plugin manager owns profile package operations. Electron retains startup preparation and native recovery; the [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns this separation.
-The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered.
+The process-lifetime Electron lock is the authoritative Desktop owner. Profile preparation and native recovery use an exclusive lock recording the Electron PID; a live owner blocks a competing operation, while a stale PID can be recovered. Web package operations use the shared plugin manager’s locking.
Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts.
@@ -77,11 +79,11 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
-Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
-Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification.
@@ -97,7 +99,7 @@ Architecture-specific builds report actual component-level compressed and instal
| Surface | Implementation |
|---|---|
-| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
+| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, profile preparation, native recovery, update coordination, and electron-builder configuration. |
| Installed runtime | Private `@deepseek-ai/dsh-desktop-host` invokes the shared profile runner and reports the authenticated Web URL to Electron. |
| Package state | Electron RunAsNode executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. |
| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
@@ -112,7 +114,7 @@ Architecture-specific builds report actual component-level compressed and instal
**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding.
-**Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned.
+**Maintain an Electron-only plugin installer.** A separate page, IPC API, and package service duplicate the shared Web manager. The shared service uses the reserved Desktop profile and launcher-supplied pnpm while keeping CLI profile access disabled.
**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback.
@@ -130,13 +132,13 @@ Architecture-specific builds report actual component-level compressed and instal
- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies.
- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature.
-- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin.
-- Every Desktop package operation uses bundled pnpm with the user’s normal environment and profile configuration.
-- The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments.
-- The backend and browser application cannot mutate desktop packages.
+- `.dsh/profiles/desktop/node_modules` stores external plugins managed by the shared Web plugin manager.
+- Desktop package operations use launcher-supplied bundled pnpm and the shared manager’s subprocess environment and profile configuration.
+- The main application’s Plugins page sends structured package and activation requests to the shared Host service.
+- The reserved Desktop profile remains unavailable to the standalone CLI.
- npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads.
-- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised.
+- The shared plugin manager owns package-failure handling; native recovery handles fatal Host failures.
- One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
- Shared `.dsh` data rejects incompatible readers before migration or mutation.
- The Web application owns HTTP authentication and serving; the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
@@ -151,8 +153,8 @@ Architecture-specific builds report actual component-level compressed and instal
|---|---|
| First launch | Check bundled release metadata and create profile links without installing core dependencies |
| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links |
-| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
-| Activation | In-place package changes followed by actual Host startup |
+| Plugin management | Shared Web Plugins page and Host service with launcher-supplied bundled pnpm |
+| Activation | Shared manager applies configuration through HMR when enabled; otherwise changes require restart |
| Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
@@ -160,14 +162,14 @@ Architecture-specific builds report actual component-level compressed and instal
Plugin lifecycle scripts execute third-party code. The profile’s `allowBuilds` configuration determines which builds may execute.
-Updating the bound dsh can invalidate plugin peer dependencies or native modules. Installed plugin files remain in place; loading failures require explicit repair through the recovery UI.
+Updating the bound dsh can invalidate plugin peer dependencies or native modules. Native recovery can disable third-party bundles and restart the application so the shared Plugins page can be used for repair.
An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
-Interrupted package operations retain partial changes. Recovery retries the actual Host or permits explicit plugin repair without an automatic package reinstall.
+Package-operation failure and cancellation follow the shared plugin manager’s restoration rules. Native recovery does not reinstall packages.
Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification.
## Related proposals
-The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) supersedes portless transport and private backend composition. Release identity, signing, process ownership, and Electron-only package authorization remain owned here.
+The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared Web transport and plugin management. Release identity, signing, and process ownership remain owned here.
diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
index fad71d59ea..2d2d5c68f2 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
@@ -24,13 +24,13 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
-浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
+Desktop Host 为保留 profile 提供共享 Web 插件管理器,并通过启动器信息提供内置 pnpm。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在 profile 恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。
## 归属
| Owner | 职责 |
|---|---|
-| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
+| Electron 壳 | 窗口与子进程生命周期、保留 desktop profile 准备、原生恢复、更新协调 |
| Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
@@ -38,7 +38,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
-渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
+渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 提供启动就绪与失败报告、原生目录选择、主题同步,以及 Windows 菜单和外观适配。它不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 菜单与原生对话框使用类型化中英文文案,并以英文回退;Windows 跟随主文档语言。共享 Web 插件管理器负责自身客户端文案。
+
+更新确认使用 shell 静态 origin,因为 Web Host 尚未就绪时也必须可用。其打包文档和资源保留与其他本地静态资源相同的方法和路径限制。
## 文件系统布局
@@ -59,9 +61,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
## 安装与解析
-Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。
+共享 Web 插件管理器负责 profile 包操作。Electron 保留启动准备和原生恢复;[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这一职责划分。
-进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。
+Electron 进程生命周期锁是 Desktop 的权威所有者。profile 准备和原生恢复使用记录 Electron PID 的排他锁;存活的所有者阻止竞争操作,失效 PID 可被回收。Web 包操作使用共享插件管理器的锁。
核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。
@@ -77,11 +79,11 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
-Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
[固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
-Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。
Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
@@ -97,7 +99,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
| 表面 | 实现 |
|---|---|
-| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
+| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、profile 准备、原生恢复、更新协调和 electron-builder 配置。 |
| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
| 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
@@ -112,7 +114,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
-**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
+**维护 Electron 专用插件安装器。** 独立页面、IPC API 和包服务会重复共享 Web 管理器的功能。共享服务使用保留 Desktop profile 和启动器提供的 pnpm,同时继续禁止 CLI 访问该 profile。
**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
@@ -130,13 +132,13 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
-- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
-- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置。
-- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
-- 后端与浏览器应用不能修改桌面包。
+- `.dsh/profiles/desktop/node_modules` 保存由共享 Web 插件管理器管理的外部插件。
+- Desktop 包操作使用启动器提供的内置 pnpm,以及共享管理器的子进程环境与 profile 配置。
+- 主应用的“插件”页面向共享 Host 服务发送结构化包操作与激活请求。
+- 独立 CLI 仍不能访问保留 Desktop profile。
- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
-- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
+- 共享插件管理器负责包操作失败处理;原生恢复负责 Host 致命故障。
- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
- Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。
@@ -151,8 +153,8 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|---|---|
| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 |
| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
-| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
-| 激活 | 直接修改包后启动实际 Host |
+| 插件管理 | 共享 Web“插件”页面与 Host 服务,使用启动器提供的内置 pnpm |
+| 激活 | 启用 HMR 时由共享管理器应用配置;否则变更需要重启 |
| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
@@ -160,14 +162,14 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行。
-更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。
+更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。原生恢复可以禁用第三方 bundle 并重启应用,之后可通过共享“插件”页面修复。
通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
-包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包。
+包操作失败与取消遵循共享插件管理器的还原规则。原生恢复不会重新安装包。
代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
## 相关提案
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 传输和插件管理。发布身份、签名与进程归属仍由本记录负责。
diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
index 3c5fbdae42..f53d8aec09 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
-2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8
-2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549
+2026-08-25-session-observations-and-projection-owned-client-state.md: 8573ea89fae6f52a9d28cc75261aea529df4f075
+2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 55c477e994df72ef8f6b6e4bb97b8b35e5783c9b
diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
index ff5628c305..8573ea89fa 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
@@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi
| Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
| Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
-The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence.
+The Client stores one row per key. A row the connected Host computed carries its sequence number: a newer baseline or frame replaces it, an equal or older input is ignored, and reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. A list hint viewed from the persisted cache carries no comparable sequence number and yields to every Host-sequenced write ([projection cache listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)).
The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority.
-The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row.
+The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames. Higher-sequence-wins applies among Host-sequenced values; cached list hints sit below all of them. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer sequenced row.
Data that is not derived from one Session remains outside projections. `session/modelCatalog` owns the Host-generation model catalog, and `agentPresets/list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict.
@@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an
### Domain applications
-- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing.
+- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection.
- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header.
- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session.
- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself.
diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
index e433df28fe..55c477e994 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
@@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同:
| Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
-Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。
+Client 为每个 key 保存一行。由建连 Host 算出的行带 sequence number:更新的 baseline 或 frame 会替换它,相同或更旧的输入被忽略,因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。从持久化缓存看出来的 list hint 不带可比的 sequence number,让位于所有 Host 排序过的写入([Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。
List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。
-每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。
+每个 Session 的 Client projection store 接收 list hints、follow baseline 和后续 whole-value frame。higher-sequence-wins 只在 Host 排序过的值之间生效;cached list hint 排在它们全部之下。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 sequenced row。
不由单个 Session 派生的数据不进入 projection。`session/modelCatalog` 持有当前 Host generation 的 model catalog,`agentPresets/list` 持有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。
@@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
### 领域应用
-- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。
+- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。
- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。
- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。
- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。
diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
index bb8c3f29df..bfe7ee764b 100644
--- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 1b0fd162be77356001bcd9224bb2ee2889e6d6c0
-2026-08-28-subprocess-native-containment.zh.md: f33643f98a6ea6a90c0780432cbe11a35a868562
+2026-08-28-subprocess-native-containment.md: 88acee2d41fab878a0e50380cbb87eaece8e7296
+2026-08-28-subprocess-native-containment.zh.md: 53406f99531953a0330abf08be150da845f37aab
diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
index 1b0fd162be..88acee2d41 100644
--- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
+++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
@@ -46,6 +46,8 @@ Linux falls back before target execution when the exact bootstrap, modern readab
Normal Cordis disposal starts direct-result and range observation independently, requests termination, and waits for every owned range. Consumer teardown does not inspect an ordinary PID; it retains the original operation or startup error while attempting terminate and final wait, and preserves cleanup failures in the consumer's existing error order. A confirmed empty range permanently disables later signalling against stale identities.
+Native Windows fixtures retain the same Job-owned termination and final wait on success and failure. A descendant PID is observation-only: issuing `taskkill` after its exit can terminate an unrelated process that has reused the number, including another concurrent test worker.
+
During a JavaScript-observable host exit, `LocalSubprocessRuntime` synchronously force-terminates every still-live handle without promises or timers. Linux sends the existing direct fallback kill and the exact scope kill; Windows kills the runner so its only Job handle closes; PTY fallback scans remain best effort. Per-handle failures are contained and do not change the host's exit result. Termination modes in which JavaScript cannot run remain outside this listener's guarantee.
## Existing decisions and supersession
@@ -54,6 +56,8 @@ This note owns the current native-containment mechanism. It partially updates th
## Verification
+The host-exit disposal fixture compares the provider's listener identities before and after disposal and verifies that an independently registered exit listener remains installed. Total process listener counts cannot establish provider cleanup because other process-lifetime handlers may register after provider construction.
+
- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
- Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
- A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.
diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
index f33643f98a..53406f9953 100644
--- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
@@ -46,6 +46,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
正常 Cordis dispose 会独立启动 direct-result 与 range observation、请求终止,并等待每个自有 range。消费方 teardown 不检查普通 PID;它会保留原始 operation 或 startup error,同时尝试 terminate 与 final wait,并按消费方既有错误顺序保留 cleanup failure。range 一旦被确认为空,就会永久禁止后续向陈旧 identity 发送信号。
+原生 Windows fixture 在成功和失败时都通过同一个 Job owner 请求终止并完成最终等待。后代 PID 仅用于观察:进程退出后再执行 `taskkill`,可能终止已复用该编号的无关进程,包括并发运行的其他测试 worker。
+
在 JavaScript 可观察的 host exit 期间,`LocalSubprocessRuntime` 会同步强制终止每个仍存活的句柄,不使用 Promise 或 timer。Linux 会发送既有 direct fallback kill 与准确 scope kill;Windows 会终止 runner,使其唯一 Job handle 关闭;PTY fallback 扫描仍是 best effort。每个句柄的失败相互隔离,也不改变宿主退出结果。JavaScript 无法运行的终止形态不属于该 listener 的保证。
## Existing decisions and supersession
@@ -54,6 +56,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
## Verification
+宿主退出清理 fixture 对比 provider 在 disposal 前后的监听器身份,并验证独立注册的退出监听器仍然存在。进程监听器总数无法证明 provider 清理完成,因为其他进程生命周期处理器可能在 provider 构造之后注册。
+
- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
- Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
- 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
index 759741d3f1..ad8ffb6167 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: d566459af64f4177ed0135813e75aaf77480823c
-2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2
+2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
+2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
index d566459af6..273b51ac7c 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
@@ -59,6 +59,7 @@ JSONL record
→ v0-to-v1 stage
→ v1-to-v2 stage
→ v2-to-v3 stage
+ → v3-to-v4 stage
→ current event collector
```
@@ -66,6 +67,8 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
### Adjacent version ownership
+The [V3-to-V4 specification](../../../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) specifies parent catalog completion: it advances the header version, preserves admitted events and inherited cuts, and appends missing own catalog entries from child evidence. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.
+
The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
The [V2-to-V3 README](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is the single specification for that edge's transformations, preservation, and refusal; its separate [native admission section](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [system-prompt](2026-09-02-system-prompt-as-surface-node.md), [PTC](../feature/2026-06-15-ptc.md), and [canonical-envelope](2026-09-06-v3-canonical-session-envelopes.md) notes retain their independent rationale, not duplicate conversion specifications. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
@@ -76,7 +79,7 @@ Preset renames cover the creation header and every selection event because the l
A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
-The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs.
+The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming-converter fixes affect later historical conversions, not existing target files; the [cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns compatibility review and alpha support policy. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. Integration tests use isolated disposable homes and unchanged historical inputs.
The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
@@ -96,10 +99,14 @@ Current encoding is record based. The provider serializes about 1 MiB of plainte
The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.
+The [browser preview packer](../../../../packages/experimental/webworker-packer/README.md) prepares current successors for bundled Session data in Node, using same-root direct-child descriptors as parent catalog evidence. The browser host does not implement `node:worker_threads`, so this preparation preserves both immutable fixture generations and runtime JSONL’s Worker verification requirement.
+
Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
+The bulk V4 migration command shares one JSONL persistence Context across a bounded job queue, retaining the backend’s two-Worker verification limit and cache for reusing recently decoded logs. A child’s concurrent publication can change its parent’s selected child generation; the ordinary revision check refuses that attempt. The command defers only this source-change error for one sequential retry after the initial queue drains, collecting fresh evidence without relaxing publication checks or retrying unrelated failures.
+
### Durable format and publication rules
Canonical filenames encode physical format generation: v0 is `session.jsonl[.zstd]` and positive generations use `session.vN.jsonl[.zstd]`. Migration never moves, replaces, or deletes a committed generation and writes only the final current target; intermediate versions exist only as stage state.
@@ -122,6 +129,44 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
| Verification repeats on the main thread | At most two complete-generation Workers | Keeps verification CPU off the main thread |
| Production and fixture migration use different APIs | Catalog `createRestore()` with explicit policies | One decoder/chain implementation |
+### Parent catalog prerequisites
+
+V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
+
+Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
+
+Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
+
+The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
+
+
+### Catalog scan measurements
+
+The package-local [diagnostic](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts) measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.
+
+On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.
+
+| Unrelated Sessions | Historical stat | Cold read | Memo read | Publish | Current warm read |
+|---:|---:|---:|---:|---:|---:|
+| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
+| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
+| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
+| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
+| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
+| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
+| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
+| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
+| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
+
+Run from the repository root after building the runtime:
+
+```sh
+pnpm run build:lib:host
+cd packages/session/session-persistence-jsonl
+node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
+node .artifacts/perf/catalog-migration.perf.mjs
+```
+
## Verification
The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
index f82b406117..69132ec176 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
@@ -59,6 +59,7 @@ JSONL record
→ v0-to-v1 stage
→ v1-to-v2 stage
→ v2-to-v3 stage
+ → v3-to-v4 stage
→ current event collector
```
@@ -66,6 +67,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
### 相邻版本所有权
+[V3 到 V4 规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)规定父目录补全:它推进 header 版本,保留已接纳的事件与继承切点,并根据子 Session 证据追加缺失的自身目录记录。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。
+
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
[V2 到 V3 README](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是该迁移边转换、保留与拒绝规则的单一规范真源;单列的[原生准入章节](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[系统提示词](2026-09-02-system-prompt-as-surface-node.zh.md)、[PTC](../feature/2026-06-15-ptc.zh.md)和[规范信封](2026-09-06-v3-canonical-session-envelopes.zh.md)记录保留各自独立依据,而非重复转换规范。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
@@ -76,7 +79,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
-[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
+[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器修复影响之后的历史转换,不影响已有目标格式文件;[cookbook](../../../../docs/cookbook/adding-a-session-format-version.zh.md)拥有兼容性评审和 alpha 支持政策。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
@@ -96,10 +99,14 @@ Current encode 以单条 record 为单位。Provider 在主线程每个 slice
发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。
+[浏览器 preview 打包器](../../../../packages/experimental/webworker-packer/README.zh.md)在 Node 中为随包 Session 数据准备当前代际后继,并使用同一根目录中直属子 Session 的 descriptor 作为父目录证据。浏览器 host 未实现 `node:worker_threads`,因此打包阶段的准备同时保留不可变 fixture 代际与运行时 JSONL 的 Worker 校验要求。
+
Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
+批量 V4 迁移命令在有界任务队列中共享一个 JSONL persistence Context,保留 backend 的双 Worker 校验上限,以及复用近期已解码日志的缓存。子 Session 并发发布可能改变父 Session 选中的子代际;普通修订检查会拒绝这次尝试。命令只把这种源变化错误延后到初始队列清空后串行重试一次,重新收集证据,不放宽发布检查,也不重试无关失败。
+
### Durable format 与 publication 规则
规范文件名编码 physical format generation:v0 使用 `session.jsonl[.zstd]`,正 generation 使用 `session.vN.jsonl[.zstd]`。Migration 不会移动、覆盖或删除任何 committed generation,并且只写最终 current target;中间版本只存在于 stage state。
@@ -122,6 +129,44 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
| 主线程重复执行完整 verification | 最多两个 complete-generation Worker | Verification CPU 不占用主线程 |
| Production 与 fixture 使用不同 migration API | Catalog `createRestore()` 加显式 policy | 只保留一套 decoder/chain 实现 |
+### 父目录前置事实
+
+V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
+
+原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
+
+历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
+
+通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
+
+
+### 目录扫描测量
+
+包内[诊断脚本](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts)测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。
+
+以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。
+
+| 无关 Session | 历史 stat | 冷读 | 缓存读取 | 发布 | 当前格式热读 |
+|---:|---:|---:|---:|---:|---:|
+| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
+| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
+| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
+| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
+| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
+| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
+| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
+| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
+| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
+
+在仓库根目录构建运行时后执行:
+
+```sh
+pnpm run build:lib:host
+cd packages/session/session-persistence-jsonl
+node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
+node .artifacts/perf/catalog-migration.perf.mjs
+```
+
## 验证
迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
index c09aaa9dcb..59e3f84862 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-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 .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
-2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848
-2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb
+2026-09-01-parent-owned-subagent-catalog.md: 536ec33148bd675a974622b98ee23de812691dc0
+2026-09-01-parent-owned-subagent-catalog.zh.md: ec78ff9e4b7d54a7309baf21c383ec5ff83a19c8
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
index 20e2dd035b..536ec33148 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
@@ -44,6 +44,6 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
-Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
+Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
-Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs.
+[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
index 6ec33e8be8..ec78ff9e4b 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
@@ -44,6 +44,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
-Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
+Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
-不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实。
+[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml
index ebe52594b7..87f3b7c351 100644
--- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
-2026-09-02-projcache-cross-version-read-compat.md: 82f7f26d5cd0484f5adcd51db17748f41a298294
-2026-09-02-projcache-cross-version-read-compat.zh.md: 58705ec8f15fbffff6af31872a69f1f19ef5e3c5
+2026-09-02-projcache-cross-version-read-compat.md: 160c5c796bacc6f672b06acc0df4246d7008c52b
+2026-09-02-projcache-cross-version-read-compat.zh.md: cff7742f6c1f887b905a2c139e4157782aef677c
diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
index 82f7f26d5c..160c5c796b 100644
--- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
+++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
@@ -39,7 +39,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write
5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values.
6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage.
-7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint uses `asOfSeq: -1` rather than the stored row sequence because a cardinality-changing log migration can remap that coordinate. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent.
+7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint carries the stored title row's own watermark as `asOfSeq`; because a cardinality-changing log migration can remap that coordinate, the Session list labels the block `kind: 'cached'` and the client never compares it with the connected Session's seqs ([listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent.
### v3-v6 → v7 disposition
diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md
index 58705ec8f1..cff7742f6c 100644
--- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md
@@ -39,7 +39,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只
5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。
6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。
-7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 使用 `asOfSeq: -1`,而不使用存储 row 的序号,因为改变日志事件数量的迁移会重新映射该坐标。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。
+7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 的 `asOfSeq` 是存储 title row 自己的水位;因为改变日志事件数量的迁移会重新映射该坐标,Session list 把该 block 标为 `kind: 'cached'`,客户端不会拿它与建连 Session 的 seq 比较([只读面按 lifecycle 身份匹配与 cached 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。
### v3-v6 → v7 处置
diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml
index 89a705592d..55f30d278a 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md
-2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0
-2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af
+2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db
+2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d
diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md
index 14128497cf..db8cb9a8bc 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md
@@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo
`Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control.
-`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself.
+`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md).
-**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does.
+**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction.
The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too.
## Finding the duplicates
-A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches.
+A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class.
## What stays local
@@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis
A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial:
- **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`.
-- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance.
- **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge.
- **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one.
- **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so.
@@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in
**A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance.
-**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation.
+**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation.
**Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule.
@@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in
`Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices.
-The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`.
+The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`.
-That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request.
+The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes.
## Consequences
- A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports.
- `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument.
-- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions.
-- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids.
+- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight.
+- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback.
- The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists.
-- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies.
+- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference.
diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md
index 66268b48f8..6d4b04a21b 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md
@@ -18,15 +18,15 @@ Status: implemented
`Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。
-`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。
+`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。
-**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。
+**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。
规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。
## 如何找出重复
-按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。
+按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。
## 保留在原包的控件
@@ -34,7 +34,6 @@ Status: implemented
机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似:
- **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。
-- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。
- **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。
- **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。
- **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。
@@ -52,7 +51,7 @@ Status: implemented
**给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。
-**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。
+**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。
**把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。
@@ -60,15 +59,15 @@ Status: implemented
`Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。
-迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。
+迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。
-这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。
+组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。
## Consequences
- 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。
- `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。
-- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。
-- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。
+- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。
+- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。
- 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。
-- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。
+- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。
diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml
index d5073475ba..577fbe18d0 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
-2026-09-05-sidebar-tab-types-and-navigation.md: 24332c844d13c41e4f966f39aa7d2fe79fd8553f
-2026-09-05-sidebar-tab-types-and-navigation.zh.md: 3aeb38e2ec9c997b9aaf040ae0cb575fa08fa235
+2026-09-05-sidebar-tab-types-and-navigation.md: 599ce36ed4bb1dfc2cce063a461903e1a4aeb40e
+2026-09-05-sidebar-tab-types-and-navigation.zh.md: e4e8de5a389fea77aae705d63cc4fb16f88af647
diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
index 24332c844d..599ce36ed4 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
@@ -36,7 +36,7 @@ interface SidebarRightTabDefinition {
`priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes.
-`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. [Guide start page and stat pill refinements](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the current description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
+`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) defines description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
### Bodies and titles: keyed Slot seats under the definition's `id`
@@ -116,6 +116,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
## Deferred
- A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
-- Parameters for the shipped page types, which today declare none.
- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.
diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
index 3aeb38e2ec..e4e8de5a38 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
@@ -36,7 +36,7 @@ interface SidebarRightTabDefinition {
`priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。
-`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[引导起始页与统计 pill 的细化](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责当前的描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
+`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
### 正文与标题:按定义 `id` keyed 的 Slot 坑位
@@ -116,6 +116,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
## Deferred
- `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
-- 随包页类型的参数,今天未声明任何。
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml
index a3a50d38ef..e834c8bf6f 100644
--- a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md
-2026-09-06-v3-canonical-session-envelopes.md: 17ec331cc1ea21f31d0b51eab86d22725b6deef8
-2026-09-06-v3-canonical-session-envelopes.zh.md: a18bb3b4d71c164e3fba7288cc686a1cb7c7b4df
+2026-09-06-v3-canonical-session-envelopes.md: 68d4386b9e06025fd44d6d9054a95fb51ec8bc3e
+2026-09-06-v3-canonical-session-envelopes.zh.md: d3bfc17fa263f8edb0b6f73ce59c626d772dbc1e
diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md
index 17ec331cc1..68d4386b9e 100644
--- a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md
+++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md
@@ -14,7 +14,7 @@ Session format V3 uses one canonical event envelope. Every `system/message`, `us
`SurfaceOp` is exactly `'append'` or `{ op: 'replace', startSeq, endSeq }`, with `SessionSeq` endpoints and no aliases or extra keys. Both endpoints precede the replacing event and identify an inclusive span in current surface order, not numeric sequence order. Session acceptance additionally verifies current membership, ordered endpoints, complete cited coverage, and content-only single-node tool-result replacement. Compaction payload fields such as `shadowedRange.start/end` and fold-result fields retain their own names; this is not a recursive payload rename.
-Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.content[0].isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
+Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
### Validation ownership
diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md
index a18bb3b4d7..d3bfc17fa2 100644
--- a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md
@@ -14,7 +14,7 @@ Session 格式 V3 使用一种规范事件信封。每个 `system/message`、`us
`SurfaceOp` 恰好为 `'append'` 或 `{ op: 'replace', startSeq, endSeq }`,端点使用 `SessionSeq`,不接受别名或额外键。两个端点都早于替换事件,并按当前 surface 顺序而非数值序号顺序标识闭区间。Session 接纳还验证当前成员关系、端点顺序、完整引用覆盖与仅修改内容的单节点工具结果替换。`shadowedRange.start/end` 等压缩(compaction)载荷字段与折叠结果字段保留各自名称;这不是对载荷进行递归重命名。
-当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.content[0].isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
+当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
### 校验所有权
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
index c2dfc82096..552cf1b424 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 61fa9eafb72133d4cbba1b65193275cc8474f7e5
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 8d94d5823526ab4e615dab13f8453c8b6da5d8b6
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
index b49ae76bfd..61fa9eafb7 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
-Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
@@ -28,32 +28,32 @@ The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.t
Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
-The shared profile runner projects missing installation and selected-bundle dependencies within the Desktop profile. pnpm-installed packages take precedence. Links resolve to real package directories under normal Node resolution, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
+The shared profile runner supplies missing installation and selected-bundle dependencies through the runtime resolution. pnpm-installed packages take precedence. Runtime resolution delegates to the selected package paths, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; runtime resolution does not merge a package’s dual implementations.
-External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared fallback links supply missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
+External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. The runtime resolution supplies missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
-The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and module links to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
+The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and runtime module resolution to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
## Transactions and upgrades
-First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links in place. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
+First launch creates profile metadata without running pnpm, preserving unrelated files. Each launch computes the runtime resolution from the current installation, including after a compatible release change or application relocation. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
-Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
+Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation.
-Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
+The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime.
-Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
+The shared Web plugin manager owns package mutations and activation; Electron retains profile preparation and native recovery. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns these responsibilities.
-The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup. The native recovery dialog can disable third-party bundles and back up the profile patch when the Web application cannot start. Installed plugin files remain available for repair.
## Alternatives considered
Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
-- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Shared fallback supplies missing packages while pnpm-owned entries retain independent versions.
-- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
-- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
+- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions.
+- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Runtime resolution requires no filesystem projections.
+- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. The runtime resolution gives ESM and CommonJS the same package selection.
- **Keep core packages in ASAR.** Ordinary `extraResources` preserves native loading and subprocess paths. ASAR requires separate package-resolution qualification.
## Consequences
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
index 58dd72124c..8d94d58235 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
@@ -28,32 +28,32 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
-共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
+共享 profile runner 通过 runtime resolution 补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。运行时解析委托给所选包路径,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;运行时解析不会合并包的双重实现。
-外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。共享补全链接提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
+外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。runtime resolution 提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
-profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和模块链接交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份。
+profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和运行时模块解析交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份。
## 事务与升级
-首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告。
+首次启动创建 profile 元数据,不运行 pnpm,并保留无关文件。每次启动都从当前安装计算 runtime resolution,包括兼容的发布变化或应用移动之后。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告。
-共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
+共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。
-依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。
+共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。
-Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。
+共享 Web 插件管理器负责包变更和激活;Electron 保留 profile 准备和原生恢复。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这些职责。
-[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host。Web 应用无法启动时,原生恢复对话框可禁用第三方 bundle 并备份 profile patch。已安装插件文件保留以供修复。
## 考虑过的替代方案
完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
-- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
-- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
-- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
+- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。
+- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。运行时解析不需要文件系统投影。
+- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。runtime resolution 为 ESM 与 CommonJS 提供相同的选包结果。
- **把核心包留在 ASAR。** 普通 `extraResources` 保留原生加载和子进程路径。ASAR 需要单独验证包解析。
## 影响
diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
index 511e949e4c..414fda0806 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793
-2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919
+2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2
+2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a
diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
index 43cc8d9357..be3a835a62 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
+++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
@@ -16,12 +16,16 @@ Document Preview separates resource observation from content reads. The [resourc
Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
-Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `
` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `
` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
+
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
## Alternatives considered
+**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
+
**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.
diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
index 44eccba894..efb87e16cc 100644
--- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
@@ -16,12 +16,16 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
-Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `
` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `
` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
+
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
## 考虑过的替代方案
+**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
+
**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
index 6a440cf660..628396c454 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
-2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae
+2026-09-09-desktop-immediate-window-and-direct-start.md: e7ccf9b7456213d69795a40d9cd2a8509adfc44e
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: c59cc91f0013293cbaf2c086822085000dd3afd2
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
index 2d325d5c9f..e7ccf9b745 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
-Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
## Problem
@@ -16,9 +16,9 @@ Electron creates the main window with the packaged Web loading page before profi
Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here.
-Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
+Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host.
-This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
+This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, and resource ownership rationale. Full runtime file verification remains a packaging operation.
## Alternatives considered
@@ -30,4 +30,4 @@ This partially supersedes staged backend probes and waiting to create the main w
Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails.
-Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests.
+Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, native recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle tests.
diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
index 8aebe6bc8c..c59cc91f00 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
## 问题
@@ -16,9 +16,9 @@ Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主
致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定。
-Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
+Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪信息提供认证后的 Host URL 和启动注入。桌面壳用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为所属应用源认证直接 WebSocket 请求。此承载适配保留 Web 路由和流语义,同时允许静态 HTML 在 Host 就绪前显示。
-本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
+本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输与资源归属的理由。完整运行时文件验证仍属于打包操作。
## 考虑过的替代方案
@@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-
用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
-验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。
+验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。
diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
index 07c6da31be..030a4eadb9 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
-2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
-2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62
+2026-09-09-plugin-management-in-the-web-sidebar.md: ba359aeefe3883445fd5f3e3dc164262e0613219
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 91664deeb422c1dc6c34f826a85f398183128d1c
diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
index d2a3329e7c..ba359aeefe 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
@@ -10,9 +10,9 @@ Installed packages belong to the running profile, while Settings is a modal over
## Decision
-**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own.
+**Management is a sidebar entry.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. It lists installed bundles and installation-provided optional bundles. Settings keeps the read-only plugin inventory, including the installation's own bundles (`dsh-base`, `dsh-web-app`); both inventory groups start collapsed, and the inventory carries no management controls. Configuration placement follows the [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md).
-**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
+**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration forms use the existing global settings bindings.
**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
@@ -20,11 +20,11 @@ Installed packages belong to the running profile, while Settings is a modal over
**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
-**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
+**Configuration placement.** The [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md) owns this choice and its alternatives; sidebar navigation and installation-request ownership remain independent of it.
## Consequences
-The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
+The web bundle's **Plugins** sidebar entry opens profile management. Settings exposes the read-only inventory, while plugin forms live on the Plugins page. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar; `plugin-config` and `settings-chrome` exercise the forms and read-only inventory respectively.
## Testing
diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
index aae035b3a9..91664deeb4 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
@@ -1,4 +1,4 @@
-# Agent Note:插件管理移到 Web 侧栏
+# Agent Note: 插件管理移到 Web 侧栏
Status: implemented
@@ -10,9 +10,9 @@ Status: implemented
## 决定
-**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。
+**管理位于侧栏。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。它列出已安装的组合包与安装随附的可选组合包。设置保留只读插件清单,包括随安装提供的组合包(`dsh-base`、`dsh-web-app`);清单的两个分组默认收起,且不带管理控件。配置的位置遵循[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)。
-**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
+**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置表单使用原有全局 settings 绑定。
**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
@@ -20,11 +20,11 @@ Status: implemented
**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
-**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
+**配置的位置。** 该选择及其替代方案由[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)维护;侧栏导航与安装请求的归属独立于它。
## 后果
-web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+web bundle 的**插件**侧栏入口打开 profile 管理。设置提供只读清单,插件表单位于插件页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器;`plugin-config` 与 `settings-chrome` 分别验证表单和只读清单。
## 测试
diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.i18n.yaml
new file mode 100644
index 0000000000..1514b7c6d3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.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-producer-owned-message-sources.md
+2026-09-09-producer-owned-message-sources.md: 83f8cb9368b516b7b6fde8717f9e5f8e9d4f7d4e
+2026-09-09-producer-owned-message-sources.zh.md: f5cdfc48286f7b92883938c8e316a7751580f52c
diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md
new file mode 100644
index 0000000000..83f8cb9368
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md
@@ -0,0 +1,33 @@
+# Agent Note: Producer-owned message sources at the V4 edge
+
+Status: implemented
+
+English | [中文](2026-09-09-producer-owned-message-sources.zh.md)
+
+## Problem
+
+Released V3 plugin attribution uses the shared wrapper `{ kind: 'plugin', plugin: '' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper.
+
+## Decision
+
+Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:` before the complete original name, while direct source kinds retain their original names. The prefix keeps a plugin named `user` separate from the human-user kind; this edge leaves direct attribution unchanged. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data.
+
+Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules.
+
+The source rewrite is owned by the V3-to-V4 migration stage and runs once while historical events become V4 events. The current V4 codec retains only the released physical framing and native V4 admission; it never sends a V4 row through the released V3 validator and has no source-conversion view in its encode, decode, restore, or row-admission paths.
+
+Recorded current V4 fixtures and writer, notification, and stream-json sidecars are producer-owned; released V2/V3 fixtures and sidecars keep plugin attribution and convert at read time through the migration. The [released session-format migrations](2026-08-31-released-session-format-migrations.md) note owns the versioning rules this edge follows.
+
+The [source-attribution policy](2026-09-17-persistence-attribution-policy.md) distinguishes durable source declarations from request-only user inputs and records the narrowly scoped compatibility promise for new attribution kinds. Frozen migration rules retain historical producer kinds even when no active producer declares them.
+
+## Alternatives considered
+
+**Keep the released plugin wrapper as the current shape.** Consumers would keep switching on one synthetic kind and the producer identity stays an untyped string; the current semantic validators would keep a second, unreleased source vocabulary.
+
+**Rewrite released rows only at scan time instead of migrating them.** Scan admission is deliberately shape-only and refuses, never repairs; a scan-time rewrite would blur released generations with current rows and leave the current artifact with version-inappropriate source identities.
+
+**Reject plugin-kind rows as ordinary corruption in the scanner.** That would let a stale released row silently truncate a current Session instead of surfacing a refusal, and would misclassify rows that full decoding still needs to diagnose.
+
+## Consequences
+
+The recorded corpus for this migration applies the frozen rename table, while current-generation validation consumes producer-owned kinds directly. Web context labels render the producer kind (`runtime-context`) instead of the released plugin string. The shared rewriter reports malformed released sources as `SessionFormatError`, so a malformed historical source is a migration refusal rather than a silent payload loss.
diff --git a/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md
new file mode 100644
index 0000000000..f5cdfc4828
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: V4 边缘上的生产者归属消息源
+
+Status: implemented
+
+[English](2026-09-09-producer-owned-message-sources.md) | 中文
+
+## Problem
+
+已发布 V3 的插件归属使用共享包装 `{ kind: 'plugin', plugin: '' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。
+
+## Decision
+
+消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:`,直接 source kind 则保留原名。该前缀将名为 `user` 的插件与人类用户 kind 区分开;本边保持直接归属不变。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。
+
+原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。
+
+来源重写由 V3 到 V4 迁移 Stage 负责,在历史事件转换为 V4 事件时只执行一次。当前 V4 编解码器只保留已发布的物理分帧和原生 V4 准入;它不会让 V4 行进入已发布 V3 校验器,编码、解码、恢复和行准入路径也没有来源转换视图。
+
+录制的当前 V4 fixtures 与 writer、notification、stream-json 侧车是生产者归属的;已发布的 V2/V3fixtures 与侧车保留 plugin 归属,读取时经迁移转换。[released session-format migrations](2026-08-31-released-session-format-migrations.zh.md)笔记拥有本边所遵循的版本规则。
+
+[消息源归属策略](2026-09-17-persistence-attribution-policy.zh.md)区分持久化 source 声明与仅用于请求的 user 输入,并记录新增归属 kind 的受限兼容性承诺。即使没有活动生产者声明,冻结迁移规则仍保留历史生产者 kind。
+
+## Alternatives considered
+
+**保留 released plugin 包装作为当前形态。** 消费方将继续特殊对待一个合成 kind,生产者身份仍是无类型字符串;当前语义校验器也会保留第二套非发布的消息源词汇。
+
+**只在扫描时重写 released 行而不是迁移它们。** 扫描准入刻意只查形态并拒绝、从不修复;扫描时重写会模糊 released 世代与当前行的界限,使当前产物保留与版本不符的来源身份。
+
+**在 scanner 中把 plugin-kind 行当作普通损坏拒绝。** 这会让过期的 released 行静默截断当前 Session,而不是给出拒绝;也会把完整解码仍需要诊断的行错误分类。
+
+## Consequences
+
+本迁移的记录语料库应用冻结重命名表,而当前代校验器直接消费生产者自有 kind。Web 上下文标签渲染生产者 kind(`runtime-context`)而不是 released plugin 字符串。共享重写器将畸形历史来源报告为 `SessionFormatError`,因此迁移会拒绝它,而不会静默丢失载荷。
diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml
index aac049979c..9e68a09547 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
-2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5
-2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f
+2026-09-09-profile-resolution-generations.md: ced79eefc8c46a43cabd5b6a79db2644526c022f
+2026-09-09-profile-resolution-generations.zh.md: 98c509488aaeaa8d1b5e722bf6e3ea6346d45c46
diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
index 09ea70e49a..ced79eefc8 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
@@ -6,29 +6,67 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
## Problem
-A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
+A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
-The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
+The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
## Decision
-Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to link mode and preserves the existing materialized lookup behavior. Internal callers and tests can select runtime mode, which installs the generation into Node's ESM and CommonJS resolvers, or dual mode, which materializes and verifies the same generation. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
+Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
### One selection algorithm
-The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
+The package traversal belongs to `@deepseek-ai/dsh-app-boot` beside profile loading. Ordinary Node, source launches, packaged executables, and the Electron Host consume the same runtime resolution and interception.
-The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
+The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped.
-Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings.
+During dependency expansion, every installation root, selected bundle root, and recursive dependency manifest uses its package's real directory as the lookup anchor. Ancestor `node_modules` searches therefore follow the location from which Node normally executes the package, not the location of a symlink pointing to it. The same declaring anchor is recorded for runtime delegation. This can change the selected version or remove an otherwise discoverable dependency when logical and real ancestors differ; it is not merely a spelling change to stored paths.
-The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. The launcher uses it by default. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison.
+Profile-local and plugin-private `node_modules` entries take precedence over runtime resolution entries. The runtime resolution records installed direct profile package names for a no-I/O native fast path. Each entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed for native resolution and successor validation.
+
+The profile-scope entries of the runtime resolution remain necessary for dependencies that the profile cannot find through its own ancestor directories. They expand installed manifest dependencies into the active profile's runtime resolution, not a separate graph for each plugin. It neither downloads packages nor scans source imports. Runtime hooks consume that table after native local candidates have been considered.
+
+### Ordinary and linked package examples
+
+In both layouts below, the profile selects `my-bundle`, whose manifest declares `bridge`; only `bridge` declares `leaf`. Neither the installation nor the profile's direct packages supplies `leaf`, and `bridge` has no private `node_modules/leaf`. The paths illustrate the two layouts exercised by the [shared CLI profile tests](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts).
+
+With ordinary installed directories, `bridge` searches its containing bundle's `node_modules` and finds version 1.0.0. The unrelated version 2.0.0 is not on that search path:
+
+```text
+/case/home/profiles/headless/node_modules/my-bundle/
+ node_modules/bridge/
+ node_modules/leaf/ # 1.0.0
+/case/dependencies/node_modules/leaf/ # 2.0.0
+```
+
+With an npm-link-style bundle and a linked transitive dependency, the bundle expands from `/case/work/my-bundle`, and the next dependency expansion starts at `/case/dependencies/bridge`. That expansion finds version 2.0.0 beside the real package:
+
+```text
+/case/home/profiles/headless/node_modules/my-bundle -> /case/npm-global/node_modules/my-bundle
+/case/npm-global/node_modules/my-bundle -> /case/work/my-bundle
+/case/work/my-bundle/node_modules/bridge -> /case/dependencies/bridge
+/case/work/my-bundle/node_modules/leaf/ # 1.0.0
+/case/dependencies/node_modules/leaf/ # 2.0.0
+```
+
+| Profile package layout | Declaring directory used to resolve `leaf` | `tsx/esm` source launch | Plain Node `lib` launch |
+|---|---|---|---|
+| Ordinary directories | `/case/home/profiles/headless/node_modules/my-bundle/node_modules/bridge` | 1.0.0 | 1.0.0 |
+| Linked bundle and bridge | `/case/dependencies/bridge` | 2.0.0 | 2.0.0 |
+
+ESM `import` and CommonJS `require` select these versions. Within each module format, importing `leaf` through profile fallback and importing it inside `bridge` reaches the same module instance. A higher-priority native profile package or an installation-reserved fallback name still wins according to the selection rules; the fallback does not force every importer to use version 2.0.0.
+
+### Source and built module identity
+
+The [source launcher](2026-07-29-dsh-source-launch-tsx-esm.md) uses tsx's ESM-only hook. tsx skips tsconfig `paths` for an importer URL containing `/node_modules/`. A logical workspace symlink used as a fallback declaring anchor can therefore select built `lib/` exports, while imports from the resulting real workspace files select `src/` through the paths map. Real declaring anchors let workspace imports follow the source map consistently; packages without a matching workspace mapping keep ordinary package-export resolution.
+
+For example, `@deepseek-ai/dsh-tools` creates its scheduler key with `Symbol()`. A Tools instance loaded from `lib/` cannot expose that scheduler through a key imported from the separate `src/` module instance. Source launches keep Tools and AgentLoop in `src/`; plain Node launches keep them in `lib/`. The scheduler retains its local Symbol; correct imports share one module instance.
### Immutable generations
-A resolver registration holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
+A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
-Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible as it does in link mode. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
+Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
The launcher constructs one startup generation. The service accepts an additive successor, but no package-manager transaction invokes replacement in this implementation.
@@ -36,7 +74,7 @@ The launcher constructs one startup generation. The service accepts an additive
The resolver uses `node-addon-require-builtin` to read `internal/modules/esm/loader` and `internal/modules/cjs/loader`. The ESM adapter wraps the per-thread singleton `CascadedLoader` resolve methods. The CommonJS adapter wraps the internal builtin's `Module._resolveFilename`; that `Module` is the same object exported by `node:module`.
-Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, generation, and after-fallback package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses a generation hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
+Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, interception, and native-after-interception package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses an interception hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
The adapters call the captured native resolver after routing. Node remains responsible for exports, import and require conditions, main files, subpaths, extensions, native caches, and error codes. Routed ESM failures replace the internal lookup anchor in Node's diagnostic with the original importer. A selected package's invalid export or missing target does not trigger another same-name candidate. CommonJS does not replace `_findPath` or reproduce `_resolveFilename`.
@@ -44,11 +82,11 @@ The guarantee covers Node's default `import`, `import()`, `import.meta.resolve`,
### Active plugin list and package metadata
-The resolution generation lists available fallback packages; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed generation is authoritative, including a miss; a service created without a generation retains native lookup for low-level embedders.
+The runtime resolution lists the packages it supplies; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed runtime resolution is authoritative, including a miss; a service created without one retains native lookup for low-level embedders.
The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. A repeated query uses the same generation and therefore cannot drift from the route used for the import. Non-Node importers that need package metadata must explicitly implement the same deterministic resolver interface.
-The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread resolver and Worker-generation lifetimes; `resolver.ts` implements generation lookup and the Node Internal adapters; `worker-bootstrap.ts` installs an inherited generation in one thread. Existing profile selection and disk materialization remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
+The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread interception and the Worker resolution lifetime; `resolver.ts` implements runtime resolution lookup and the Node Internal adapters; `worker-bootstrap.ts` installs the inherited runtime resolution in one thread. Profile selection and runtime resolution construction remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
The service definition and provider remain together in `app-boot` because profile boot owns the resolver lifetime. Extracting a separate capability seam becomes warranted when a launcher-independent provider or independently evolving consumers require it.
@@ -64,21 +102,11 @@ A caller adding a package completes its pnpm transaction before constructing a s
Replacing, upgrading, or removing an already loaded package requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Generation replacement does not claim to unload modules.
-### Disk migration
+### Filesystem and runtime carriers
-Runtime-only launch paths do not create, update, or retire symlinks and proxy packages. The resolver treats the legacy shared fallback and `.dsh-module-fallback` projections as virtual insertion positions: a generation hit uses the table target, while a miss skips those old positions before continuing native ancestor lookup. During a dual phase, the launcher materializes and installs the same generation; tests disable each backend in turn and compare their targets.
+The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver.
-Legacy disk state remains available to link-only launches, old processes, and rollback without participating in runtime-only selection. Removing that state is a separate maintenance operation outside this change.
-
-### Mode behavior
-
-Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
-
-The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly.
-
-Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
-
-Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links.
+Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime resolution mechanism as ordinary Node launches.
### Performance and verification
@@ -86,11 +114,13 @@ Generation construction is startup or update work, not resolve work, and its abs
One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost.
-Behavior tests compare the runtime generation with the disk materializer over the same package trees, then exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, and explicit CommonJS options. The Node compatibility matrix runs the resolver, service, and bootstrap specifications across the supported internal-loader variants. Worker tests verify environment-data publication and bootstrap installation with mocked thread and native-loader interfaces; they do not launch a built Worker. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
+Behavior tests exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, and explicit CommonJS options. The Node compatibility matrix runs the resolver, service, and bootstrap specifications across the supported internal-loader variants. Worker tests verify environment-data publication and bootstrap installation with mocked thread and native-loader interfaces; they do not launch a built Worker. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
## Alternatives considered
-**Keep disk projections permanently.** This preserves native lookup without process hooks, but retains cross-process mutation, stale generations, proxy manifests, writer locks, and packaged-runtime divergence. A bounded dual migration remains useful because both backends consume the same generation.
+**Keep disk projections permanently.** This preserves native lookup without process hooks, but retains cross-process mutation, stale generations, proxy manifests, writer locks, and packaged-runtime divergence. Retaining link and dual as comparison modes would also keep the materializer and its lifecycle without a supported launcher that needs them.
+
+**Preserve logical symlink anchors.** This keeps lookup under the symlink's ancestors, but can select a different dependency from the one imported by the real package and can suppress tsx's workspace paths map. Canonical anchors keep eager dependency discovery and runtime delegation aligned with the package's execution location.
**Expand the dependency graph lazily during resolve.** This spreads manifest reads and errors across first-use calls, changes timing from the disk implementation, complicates Worker startup, and makes the hot path depend on graph size. Complete generation construction is easier to compare and replace atomically.
@@ -104,15 +134,15 @@ Behavior tests compare the runtime generation with the disk materializer over th
## Verification
-- One eager computation supplies the retained disk materializer and runtime generation.
-- Link-only, dual, and runtime-only tests consume the same generation; runtime startup neither writes nor retires module-resolution data.
+- One eager computation supplies the runtime resolution; startup neither writes nor retires module-resolution data.
+- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors.
+- [Source-launch tests](../../../../apps/cli/tests/source-launch.compat.spec.ts) and [built-bin tests](../../../../apps/cli/tests/built-bin.e2e.ts) run both profile layouts through the real CLI. They assert ESM/CJS versions, loaded paths, per-format dependency identity, and consistent Tools/AgentLoop module instances with an accessible scheduler key.
- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
- ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
- Production metadata lookup does not record Loader import results or wrap Entry, registry, tree, or HMR methods.
- The Node compatibility matrix runs main-thread resolver specifications across supported loader interfaces; service and bootstrap specifications cover Worker environment-data and installation interfaces without launching a built Worker.
- One-off built plain-Node measurements produced the hot and cold observations above against no-hook Node; the script and results are not committed evidence.
-- Package READMEs, architecture references, generated catalogs, and the bilingual pair describe the shipped implementation.
## Consequences
-Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
+Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
index 0f852e9d31..98c509488a 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
@@ -6,29 +6,67 @@ Status: implemented
## Problem
-profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
+profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。
-运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
+运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
## Decision
-profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 link 模式,保留现有的物化查找行为。内部调用方和测试可以选择 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以选择 dual 模式,同时物化并校验同一份 generation。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
+profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
### 唯一选包算法
-包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
+包遍历属于 `@deepseek-ai/dsh-app-boot`,与 profile 加载代码放在一起。普通 Node、源码启动、打包可执行文件和 Electron Host 消费相同的 runtime resolution 与拦截。
-安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
+安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。已声明但未安装的包会被跳过。
-profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。
+依赖展开时,每个安装根、所选 bundle 根和递归依赖 manifest 都以所属包的真实目录作为查找锚点。因此,祖先 `node_modules` 查找沿 Node 通常执行该包的位置进行,而不是沿指向该包的软链接位置进行。同一声明锚点会被记录下来,供运行时委托解析使用。逻辑路径与真实路径的祖先不同时,这可能改变所选版本,也可能使原本能发现的依赖不再被收录;它不只是对已存路径换一种写法。
-旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。launcher 默认调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。
+profile 本地和插件私有 `node_modules` 条目优先于 runtime resolution 条目。runtime resolution 记录已安装的 profile 直接包名,用于无 I/O 的原生快速分流。每个条目记录包名、版本、选定的查找目录、声明该边的 manifest 锚点和作用域,供原生解析与后继 generation 校验使用。
+
+对于 profile 无法通过自身祖先目录找到的依赖,runtime resolution 的 profile 作用域条目仍然必要。它们将已安装 manifest 中的依赖展开到当前 profile 的 runtime resolution,而不是为每个插件建立独立依赖图。它既不下载包,也不扫描源码 import。运行时钩子在考虑原生本地候选后使用这张表。
+
+### 普通安装与软链接示例
+
+下面两种布局中,profile 都选中 `my-bundle`,其 manifest 声明 `bridge`;只有 `bridge` 声明 `leaf`。安装包集合和 profile 的直接包都不提供 `leaf`,`bridge` 也没有私有 `node_modules/leaf`。这些路径示意了[共享 CLI(命令行界面)profile 测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)覆盖的两种布局。
+
+普通安装目录中,`bridge` 向上查找所属 bundle 的 `node_modules`,找到版本 1.0.0。另一个版本 2.0.0 不在这条查找路径上:
+
+```text
+/case/home/profiles/headless/node_modules/my-bundle/
+ node_modules/bridge/
+ node_modules/leaf/ # 1.0.0
+/case/dependencies/node_modules/leaf/ # 2.0.0
+```
+
+bundle 采用 npm-link 风格链接,且其传递依赖也被软链接时,bundle 从 `/case/work/my-bundle` 展开,下一层依赖则从 `/case/dependencies/bridge` 开始展开。该次展开找到真实包旁边的版本 2.0.0:
+
+```text
+/case/home/profiles/headless/node_modules/my-bundle -> /case/npm-global/node_modules/my-bundle
+/case/npm-global/node_modules/my-bundle -> /case/work/my-bundle
+/case/work/my-bundle/node_modules/bridge -> /case/dependencies/bridge
+/case/work/my-bundle/node_modules/leaf/ # 1.0.0
+/case/dependencies/node_modules/leaf/ # 2.0.0
+```
+
+| Profile 包布局 | 解析 `leaf` 时使用的声明目录 | `tsx/esm` 源码启动 | 普通 Node `lib` 启动 |
+|---|---|---|---|
+| 普通目录 | `/case/home/profiles/headless/node_modules/my-bundle/node_modules/bridge` | 1.0.0 | 1.0.0 |
+| bundle 与 bridge 均为软链接 | `/case/dependencies/bridge` | 2.0.0 | 2.0.0 |
+
+ESM `import` 和 CommonJS `require` 都选中这些版本。在每种模块格式内部,通过 profile fallback 导入 `leaf` 与在 `bridge` 内部导入它,会得到同一个模块实例。优先级更高的原生 profile 包,或安装集合已保留的同名 fallback,仍按选包规则优先;fallback 不会强制所有导入方都使用版本 2.0.0。
+
+### 源码与构建产物的模块身份
+
+[源码启动器](2026-07-29-dsh-source-launch-tsx-esm.zh.md)使用 tsx 的 ESM-only 钩子。导入方 URL 含有 `/node_modules/` 时,tsx 会跳过 tsconfig `paths`。因此,把 workspace 软链接的逻辑路径用作 fallback 声明锚点,可能选中构建后的 `lib/` 导出,而这些模块在真实 workspace 路径中发起的 import 又通过 paths 映射选中 `src/`。真实声明锚点让 workspace import 一致遵循源码映射;没有匹配 workspace 映射的包仍使用普通包导出解析。
+
+例如,`@deepseek-ai/dsh-tools` 使用 `Symbol()` 创建 scheduler 键。从 `lib/` 加载的 Tools 实例,无法通过另一个 `src/` 模块实例导入的键暴露该 scheduler。源码启动让 Tools 与 AgentLoop 都位于 `src/`;普通 Node 启动让两者都位于 `lib/`。scheduler 保留模块本地的 Symbol;正确的 import 共享同一个模块实例。
### 不可变 generation
-一个解析器 registration 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
+一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
-选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会像 link 模式一样变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
+选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
launcher 只构造启动 generation。服务接受新增型后继 generation,但本实现没有包管理器事务调用替换操作。
@@ -36,7 +74,7 @@ launcher 只构造启动 generation。服务接受新增型后继 generation,
resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader` 和 `internal/modules/cjs/loader`。ESM 适配器包装每线程单例 `CascadedLoader` 的 resolve 方法。CommonJS 适配器包装内部 builtin 导出的 `Module._resolveFilename`;该 `Module` 与 `node:module` 导出的对象相同。
-两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、generation 和 after-fallback 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 generation 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
+两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、interception 和 native-after-interception 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 interception 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
适配器完成路由后调用捕获的原生 resolver。exports、import/require conditions、main、subpath、扩展名、原生缓存和错误码仍归 Node 处理。路由后的 ESM 失败会把 Node 诊断中的内部查找锚点替换为原始 importer。选中包的无效 export 或缺失目标不会触发另一个同名候选。CommonJS 不替换 `_findPath`,也不复制 `_resolveFilename`。
@@ -44,11 +82,11 @@ resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader
### 活动插件列表与包元数据
-resolution generation 列出可用 fallback 包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 generation 后,即使查询未命中也以 generation 为准;底层嵌入方只安装服务而不提供 generation 时,服务保留 Node 原生查找。
+runtime resolution 列出它提供的包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 runtime resolution 后,即使查询未命中也以它为准;底层嵌入方只安装服务而不提供 runtime resolution 时,服务保留 Node 原生查找。
解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。重复查询读取同一个 generation,因此不会偏离 import 使用的路线。需要包元数据的非 Node importer 必须显式实现同一个确定性 resolver 接口,不能把调用来源推断重新引入 Node 主路径。
-实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程 resolver 与 Worker generation 的生命周期;`resolver.ts` 实现 generation 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 generation。旧 profile 选包和磁盘 materialize 逻辑留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
+实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程拦截与 Worker runtime resolution 的生命周期;`resolver.ts` 实现 runtime resolution 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 runtime resolution。profile 选包和 runtime resolution 构造留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
服务定义与提供方继续放在 `app-boot`,因为 profile boot 拥有 resolver 生命周期。出现与 launcher 无关的提供方或需要独立演进的消费方时,再抽出单独的能力 seam。
@@ -64,21 +102,11 @@ resolution generation 列出可用 fallback 包;Loader entries 组成活动插
替换、升级或删除已加载包需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。generation 换代不声称卸载模块。
-### 磁盘迁移
+### 文件系统与运行时载体
-runtime-only 启动流程不创建、更新或退休 symlink 和代理包。resolver 把旧共享 fallback 和 `.dsh-module-fallback` 投影视为虚拟插入位置:generation 命中时使用表中目标,未命中时越过旧位置继续原生祖先查找。dual 阶段物化并安装同一个 generation;测试分别禁用一个后端并比较目标。
+解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
-旧磁盘状态继续供 link-only 启动、旧进程和回滚使用,但不参与 runtime-only 选择。清理旧链接是本次变更之外的独立维护操作。
-
-### 模式行为
-
-link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
-
-普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。
-
-runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
-
-pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。
+运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime resolution 机制。
### 性能与验证
@@ -86,11 +114,13 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
-行为测试在同一包树上比较运行时 generation 与磁盘 materializer,再覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions 和显式 CommonJS options。Node 兼容矩阵会在受支持的内部 loader 变体上运行 resolver、service 和 bootstrap 规格。Worker 测试通过 mock 线程与 native loader 接口验证 environment data 发布和 bootstrap 安装,但不会启动构建后的 Worker。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
+行为测试覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions 和显式 CommonJS options。Node 兼容矩阵会在受支持的内部 loader 变体上运行解析器、服务和 bootstrap 规格。Worker 测试通过 mock 线程与原生 loader 接口验证 environment data 发布和 bootstrap 安装,但不会启动构建后的 Worker。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
## Alternatives considered
-**永久保留磁盘投影。** 这能在没有进程 hook 时沿用原生查找,但仍有跨进程写入、陈旧 generation、代理 manifest、写锁和打包运行时差异。迁移期 dual 模式仍有价值,因为两个后端消费同一个 generation。
+**永久保留磁盘投影。** 这能在没有进程钩子时沿用原生查找,但仍有跨进程写入、陈旧 generation、代理 manifest、写锁和打包运行时差异。把 link 与 dual 保留为对比模式,也会留下没有受支持启动器需要的物化器及其生命周期。
+
+**保留软链接逻辑锚点。** 这会沿软链接的祖先查找,但可能选中与真实包内部 import 不同的依赖,也可能抑制 tsx 的 workspace paths 映射。规范化锚点让 eager 依赖发现与运行时委托解析都对齐包的执行位置。
**在 resolve 时惰性扩展依赖图。** 这会把 manifest 读取和错误分散到首次使用,改变磁盘实现的时机,使 Worker 启动更复杂,并让热路径成本随依赖图变化。完整构造 generation 更容易比较和原子替换。
@@ -104,15 +134,15 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
## Verification
-- 一次 eager 计算同时供应保留的磁盘 materializer 和运行时 generation。
-- link-only、dual 和 runtime-only 测试消费同一个 generation;runtime 启动既不写入也不退休模块解析数据。
+- 一次 eager 计算供应 runtime resolution;启动既不写入也不退休模块解析数据。
+- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。
+- [源码启动测试](../../../../apps/cli/tests/source-launch.compat.spec.ts)与[构建入口测试](../../../../apps/cli/tests/built-bin.e2e.ts)通过真实 CLI 运行两种 profile 布局,断言 ESM/CJS 版本、加载路径、各模块格式内的依赖身份,以及一致的 Tools/AgentLoop 模块实例和可访问的 scheduler 键。
- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
- ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
- 生产 package metadata 查询不记录 Loader import 结果,也不包装 Entry、registry、tree 或 HMR 方法。
- Node 兼容矩阵会在受支持的 loader 接口上运行主线程 resolver 规格;service 和 bootstrap 规格覆盖 Worker environment data 与安装接口,但不会启动构建后的 Worker。
- 一次性 plain Node 构建产物测量得到上述相对无 hook Node 的热路径和 cold 观察结果;脚本与结果并未提交为证据。
-- package README、架构引用、生成目录和双语文档对描述已交付实现。
## Consequences
-runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
+runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
index e4d4d80da4..97cdb07d3f 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
-2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3
-2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296
+2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c
+2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7
diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
index 35ac15f5e4..d1a5b9d963 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
@@ -14,7 +14,7 @@ Workspace Files serves both file content and workspace navigation. Applying work
`list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate.
-`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
+`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox.
@@ -26,4 +26,4 @@ The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging
## Consequences
-Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates.
+Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. With Developer tools enabled, a previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates.
diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
index 559a59af0b..f8b0fae432 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
@@ -14,7 +14,7 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应
`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。
-`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
+`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
[Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。
@@ -26,4 +26,4 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应
## Consequences
-持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。
+持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。开启开发者工具时,预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。
diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml
similarity index 57%
rename from .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml
index 6009b3c1ad..484c0c2a3b 100644
--- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml
@@ -1,6 +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-04-web-clickable-link-styles.md
-2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062
-2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md
+2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5
+2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4
diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md
new file mode 100644
index 0000000000..6c1f29c555
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md
@@ -0,0 +1,29 @@
+# Agent Note: Compact Tool Details from Recorded Results
+
+Status: implemented
+
+English | [中文](2026-09-10-compact-tool-details.zh.md)
+
+## Problem
+
+Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls.
+
+## Decision
+
+The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result.
+
+Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection.
+
+Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals.
+
+This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative.
+
+## Alternatives considered
+
+**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it.
+
+**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy.
+
+## Consequences
+
+Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md
new file mode 100644
index 0000000000..d0822a48f1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Compact Tool Details from Recorded Results
+
+Status: implemented
+
+[English](2026-09-10-compact-tool-details.md) | 中文
+
+## 问题
+
+目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。
+
+## 决策
+
+`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。
+
+工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。
+
+任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。
+
+这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。
+
+## 考虑过的替代方案
+
+**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。
+
+**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。
+
+## 影响
+
+已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。
diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml
new file mode 100644
index 0000000000..993f4391a6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
+2026-09-10-component-factories-and-local-slots.md: 801e77644d9d2c80c4a9f3cad263420d1deda163
+2026-09-10-component-factories-and-local-slots.zh.md: 3ccd5f01633a1aa4ae830e1ee41243f5d1b65cc5
diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
new file mode 100644
index 0000000000..801e77644d
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
@@ -0,0 +1,105 @@
+# Agent Note: Reusable component factories with local slots
+
+Status: implemented
+
+English | [中文](2026-09-10-component-factories-and-local-slots.zh.md)
+
+## Problem
+
+The browser Slot system starts with a parent-owned extension position. A parent entry declares a child through `children`, and unrelated plugins may then register implementations into that position. The declaration fixes the child Slot's kind, scope, render authority, and lifetime.
+
+A reusable component assembly has the opposite ownership direction. One package defines the assembly, unrelated parents render independent occurrences, and each parent may choose a different Component for a named internal region. An ordinary Slot cannot represent this relationship because its definition belongs to one parent position in the global Slot tree.
+
+The defining and consuming packages compile independently. TypeScript cannot infer a selected Component's props from a runtime registration in another package, and a separately maintained flattened props type would duplicate the store, injection, locale, child-render, and scope declarations.
+
+## Decision
+
+`ui-slots` and `ui-renderer` provide named Component Factories beside the [ordinary Slot system](2026-07-22-slot-type-chain-implementation.md). `registerFactory()` installs one reusable definition, `renderFactorySlot()` renders an occurrence, and the definition reads caller-selected local Components through `useFactorySlot()`.
+
+### Opposite registration directions
+
+Ordinary Slots and Component Factories retain distinct ownership models.
+
+| Property | Ordinary Slot | Component Factory |
+|---|---|---|
+| First declaration | Parent declares a child Slot | Definition owner declares the Factory |
+| Later operation | Child registers into the parent position | Parent renders an occurrence |
+| Static authority | `SlotMap` describes the position | `SlotFactoryMap` describes the complete definition |
+| Live definitions | Multiple entries may occupy cells | One definition owns one Factory name |
+| Parent input | `renderSlot()` owner and keyed props | `renderFactorySlot()` occurrence props |
+| Parent-selected Component | Registry routing selects entries | Caller selects one Component per local slot |
+| Descendant extension points | Entry-owned ordinary `children` | Definition-owned ordinary `children` |
+
+`SlotFactoryMap` declaration-merges the complete static definition:
+
+```text
+interface SlotFactoryDef {
+ scope: SlotScope
+ props?: object
+ children?: ChildrenDecl
+ store?: StoreDecl
+ inject?: object
+ locale?: keyof LocaleNamespaceMap & string
+ slots?: Record
+}
+```
+
+The map is the only type authority. `registerFactory()` checks the runtime definition and main Component against its map entry. Store declarations normalize through `HandleOf`, so registration accepts one shared handle or one factory that produces that handle, never a nested factory. `FactoryComponentPropsOf` and `FactoryLocalComponentPropsOf` derive the complete Component props from the same entry; definition owners and consumers do not restate a flattened shared type.
+
+### Definition and occurrence lifecycle
+
+A Factory name has one live definition in a registry ledger separate from ordinary Slot cells. Registration follows the caller's Cordis effect. Disposal removes the definition, collapses its ordinary child declarations, notifies mounted outlets, and invalidates retained child-render or local-Component authority.
+
+Each `renderFactorySlot()` call creates an occurrence whose identity follows its React position and `key`. Definition replacement remounts occurrences. Definition Components and their fallback local Components report failures against the definition, while caller-selected local Components report against the caller registration; nested Factory renders preserve the same owner. All failures remain contained to that occurrence without retiring the shared definition. Assembly errors propagate, while ownership and stale-authorization failures report like ordinary component failures. The outer error boundary resets with the Factory scope incarnation, and each local boundary resets with its local slot's scope incarnation.
+
+Factory scope follows the scope binding at the occurrence's React position. `renderFactorySlot()` accepts no Session id or scope target. A strict `session` Factory requires a current binding and remounts when its identity changes; a `session-maybe` Factory preserves its first empty-to-Session adoption and remounts on later identity changes, matching ordinary Slot behavior.
+
+A shared store handle keeps the ordinary handle-by-scope behavior, including requiring a Session binding for either non-root scope. An exclusive store factory creates one handle when an occurrence first materializes and rejects that handle if it declares `spec.persist`, because registration must remain lazy and independently live occurrences cannot share one persistence key safely. Render-time records stay weak until an idempotent effect setup retains the committed occurrence; effect cleanup removes the strong mounted reference, while the occurrence-keyed WeakMap preserves identity during React effect replay and permits collection after unmount.
+
+### Local slots and ordinary children
+
+The caller may select one Component for each name in the Factory's `slots` declaration. The Factory calls `useFactorySlot(name, fallback)` and receives an identity-stable bound Component that accepts only that local slot's occurrence props. The renderer supplies the Factory's store, injection, locale, child renderers, and the local slot's own standard scope props when the bound Component renders.
+
+Local slots have no list, keyed, or chain routing and no independent registration lifetime. Multi-contributor extension points remain ordinary child Slots declared by the Factory. Those child declarations are global to the definition, while every occurrence renders their registered entries under its inherited scope.
+
+Live inspection represents each definition as a `type: 'factory'` node and nests its ordinary child Slots beneath it. Ordinary nodes retain `type: 'slot'` and their existing `kind`; callers select a Factory root with `factory:`.
+
+Every renderer-created Component receives `renderFactorySlot`, so a Factory occurrence needs no parent-side use declaration. Local selection remains per occurrence and does not introduce a runtime value import from the defining package.
+
+### First shipped use
+
+`ui-conversation` registers the optional-Session `conversation.content` Factory around the shared body and Composer. Its strict-Session `views` local position defaults to an adapter that renders the existing `conversation.session` Slot; another occurrence can select a different view Component without mounting the main Conversation Header.
+
+The Factory does not own the Conversation store. The ordinary `conversation.session` body and `conversation.session.header` retain one shared strict-Session handle, preserving draft and View-selection identity without mounting that handle under both `session` and `session-maybe` scopes.
+
+### Type and runtime enforcement
+
+The type chain rejects unknown Factory names, missing or extra occurrence props, child specs that disagree with `SlotMap`, definition fields that disagree with `SlotFactoryMap`, nested store factories, unknown local names, incompatible selected Components, and overlapping ownership among input, registration, injection, and scope props.
+
+Runtime checks cover dynamically assembled and plain-JavaScript callers: duplicate definitions, child-declaration conflicts, undeclared local names, recursive rendering, prop collisions, stale authority, strict-scope absence, and component failure isolation. Type and runtime tests also pin independent stores per occurrence, shared handles by scope, fallback behavior before registration, definition replacement, local scope projection, and ordinary child rendering.
+
+## Alternatives considered
+
+**Reuse an ordinary Slot as a portable definition.** An ordinary Slot belongs to one parent declaration and one position in the global tree. Reusing it elsewhere would borrow the wrong ownership and lifetime.
+
+**Move the main Conversation Header into the Factory.** Only the main host renders that Header. Keeping it outside the Factory lets embedded occurrences omit it without another local selection and preserves its existing strict-Session Slot lifecycle.
+
+**Move the shared Conversation store onto the optional-Session Factory.** The Header and Session body share one strict-Session handle. Mounting that handle on both the `session-maybe` Factory and the `session` Header would violate the one-handle-one-scope rule.
+
+**Register the same assembly under every parent.** Separate registrations would duplicate the definition and its child declarations. Global contributors would need parallel child names or conflicting declarations.
+
+**Maintain a flattened shared props type.** This repeats facts already present in `children`, `store`, `inject`, `locale`, and scope fields, allowing the declaration and Component props to drift.
+
+**Pass React nodes or render callbacks as business props.** Those values bypass renderer-supplied scope props, store and injection assembly, stale-authority checks, and local Component type checking.
+
+**Give local slots ordinary Slot routing.** Ordinary Slots already own multi-contributor routing. A local slot represents one caller choice for one occurrence.
+
+**Pass Session identity to `renderFactorySlot()`.** The occurrence inherits its render-position scope like an ordinary Slot. A second identity argument would create two authorities that can disagree; independently addressed Session providers are a separate capability.
+
+## Consequences
+
+Feature packages can publish one reusable UI assembly without runtime-importing its Component from consumers. Each occurrence gets independently selected local Components and exclusive state while preserving global ordinary child contributions and the existing scope, locale, injection, and store rules.
+
+The additional registry ledger and occurrence bookkeeping increase renderer complexity. Factory definitions must be globally unique, local slots intentionally support only one selected Component, and the API does not itself create an independently addressed Session scope.
+
+The Factory type and runtime tests are the executable compatibility record. The Slots subsystem reference and the `ui-slots` and `ui-renderer` package references document the consumer API.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md
new file mode 100644
index 0000000000..3ccd5f0163
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md
@@ -0,0 +1,105 @@
+# Agent Note: 带局部 slot 的可复用组件 Factory
+
+Status: implemented
+
+[English](2026-09-10-component-factories-and-local-slots.md) | 中文
+
+## 问题
+
+浏览器 Slot 系统从 parent-owned 扩展位置开始。parent entry 通过 `children` 声明 child,之后互不相关的插件可以向该位置注册实现。声明固定 child Slot 的 kind、scope、渲染权限与生命周期。
+
+可复用组件装配采用相反的所有权方向。一个包定义装配,互不相关的 parents 渲染独立 occurrence,并且每个 parent 可以为具名内部区域选择不同 Component。普通 Slot 无法表示这种关系,因为它的 definition 属于全局 Slot 树中的一个 parent 位置。
+
+定义包与消费包独立编译。TypeScript 无法从另一个包中的运行时 registration 推导所选 Component 的 props,另行维护的扁平 props 类型则会重复 store、injection、locale、child-render 与 scope 声明。
+
+## 决策
+
+`ui-slots` 与 `ui-renderer` 在[普通 Slot 体系](2026-07-22-slot-type-chain-implementation.zh.md)之外提供具名 Component Factory。`registerFactory()` 安装一个可复用 definition,`renderFactorySlot()` 渲染一个 occurrence,definition 通过 `useFactorySlot()` 读取调用方选择的局部 Component。
+
+### 相反的注册方向
+
+普通 Slot 与 Component Factory 保留不同的所有权模型。
+
+| 属性 | 普通 Slot | Component Factory |
+|---|---|---|
+| 首个声明 | Parent 声明 child Slot | Definition owner 声明 Factory |
+| 后续操作 | Child 注册进 parent 位置 | Parent 渲染 occurrence |
+| 静态权威 | `SlotMap` 描述位置 | `SlotFactoryMap` 描述完整 definition |
+| 有效 definitions | 多个 entries 可以占据 cells | 一个 definition 独占一个 Factory 名 |
+| Parent 输入 | `renderSlot()` owner 与 keyed props | `renderFactorySlot()` occurrence props |
+| Parent 选择的 Component | Registry routing 选择 entries | 调用方为每个局部 slot 选择一个 Component |
+| 后代扩展点 | Entry-owned 普通 `children` | Definition-owned 普通 `children` |
+
+`SlotFactoryMap` 通过声明合并给出完整静态 definition:
+
+```text
+interface SlotFactoryDef {
+ scope: SlotScope
+ props?: object
+ children?: ChildrenDecl
+ store?: StoreDecl
+ inject?: object
+ locale?: keyof LocaleNamespaceMap & string
+ slots?: Record
+}
+```
+
+该 map 是唯一类型权威。`registerFactory()` 根据对应 map 条目检查运行时 definition 与主 Component。Store 声明通过 `HandleOf` 规范化,因此 registration 只接受一个共享 handle 或一个生成该 handle 的 factory,绝不接受嵌套 factory。`FactoryComponentPropsOf` 和 `FactoryLocalComponentPropsOf` 从同一条目推导完整 Component props;definition owner 与消费方无需重述扁平共享类型。
+
+### Definition 与 occurrence 生命周期
+
+每个 Factory 名在独立于普通 Slot cells 的 registry ledger 中只有一个有效 definition。registration 遵循调用方的 Cordis effect。dispose 会移除 definition、折叠其普通 child 声明、通知已挂载 outlets,并令保留的 child-render 或局部 Component 权限失效。
+
+每次 `renderFactorySlot()` 调用都会创建 occurrence,其 identity 由 React 位置和 `key` 决定。替换 definition 会重新挂载 occurrence。Definition Component 及其 fallback 局部 Component 的失败归属 definition,调用方所选局部 Component 的失败归属调用方 registration;嵌套 Factory 渲染保留同一 owner。所有失败都限制在当前 occurrence 内,不会移除共享 definition。装配错误继续向外传播,所有权与陈旧授权错误则像普通组件失败一样上报。外层错误边界随 Factory scope incarnation 重置,每个局部边界随自身局部 slot 的 scope incarnation 重置。
+
+Factory scope 跟随 occurrence 所在 React 位置的 scope binding。`renderFactorySlot()` 不接受 Session id 或 scope target。严格 `session` Factory 要求当前 binding 存在,并在 identity 变化时重新挂载;`session-maybe` Factory 保留首次从空状态采纳 Session 的过程,并在后续 identity 变化时重新挂载,与普通 Slot 行为一致。
+
+共享 store handle 保留普通模式的 handle-by-scope 行为,包括两个非 root scope 均要求 Session binding。独占 store factory 在 occurrence 首次物化时创建一个 handle;若该 handle 声明 `spec.persist`,renderer 会拒绝它,因为 registration 必须保持 lazy,且多个同时存活的独立 occurrence 无法安全共享一个 persistence key。渲染期记录在幂等 effect setup 保留已 commit occurrence 前只持有弱引用;effect cleanup 会移除 mounted 强引用,而 occurrence-keyed WeakMap 在 React effect replay 期间保留 identity,并允许实例在卸载后被回收。
+
+### 局部 slots 与普通 children
+
+调用方可以为 Factory `slots` 声明中的每个名称选择一个 Component。Factory 调用 `useFactorySlot(name, fallback)`,取得 identity 稳定的绑定 Component;该 Component 只接受对应局部 slot 的 occurrence props。绑定 Component 渲染时,renderer 提供 Factory 的 store、injection、locale、child renderers 与该局部 slot 自身的标准 scope props。
+
+局部 slots 没有 list、keyed 或 chain routing,也没有独立 registration 生命周期。多贡献方扩展点仍使用 Factory 声明的普通 child Slots。这些 child 声明在 definition 范围内全局共享,而每个 occurrence 都在继承的 scope 下渲染其 registered entries。
+
+实时检查将每个 definition 表示为 `type: 'factory'` 节点,并把其普通 child Slots 嵌套在该节点下。普通节点保留 `type: 'slot'` 与现有 `kind`;调用方通过 `factory:` 选择 Factory 根节点。
+
+每个由 renderer 创建的 Component 都会收到 `renderFactorySlot`,因此 Factory occurrence 不需要 parent-side use declaration。局部选择仍然属于单个 occurrence,并且不会引入对定义包的运行时 value import。
+
+### 首个交付用途
+
+`ui-conversation` 在共享正文与 Composer 外注册 optional-Session `conversation.content` Factory。其 strict-Session `views` 局部位置默认使用一个渲染现有 `conversation.session` Slot 的 adapter;其他 occurrence 可以选择不同的 View Component,且不会挂载主 Conversation Header。
+
+Factory 不拥有 Conversation store。普通 `conversation.session` body 与 `conversation.session.header` 保留同一个 strict-Session handle,在保持草稿与 View 选择 identity 的同时,避免将该 handle 同时挂到 `session` 和 `session-maybe` scope。
+
+### 类型与运行时强制规则
+
+类型链拒绝未知 Factory 名、缺失或多余的 occurrence props、与 `SlotMap` 不一致的 child spec、与 `SlotFactoryMap` 不一致的 definition 字段、嵌套 store factory、未知局部名称、不兼容的选中 Component,以及 input、registration、injection 与 scope props 之间的所有权重叠。
+
+运行时检查覆盖动态装配与纯 JavaScript 调用方:重复 definitions、child 声明冲突、未声明的局部名称、递归渲染、prop 冲突、陈旧权限、严格 scope 缺失与组件失败隔离。类型和运行时测试还固定了各 occurrence 的独占 store、按 scope 共享的 handles、注册前 fallback 行为、definition 替换、局部 scope 投影与普通 child 渲染。
+
+## 考虑过的替代方案
+
+**把普通 Slot 复用为可移植 definition。** 普通 Slot 属于一个 parent 声明以及全局树中的一个位置。在其他位置复用它会借用错误的所有权与生命周期。
+
+**把主 Conversation Header 移进 Factory。** 只有主 host 渲染该 Header。将其留在 Factory 外,使嵌入式 occurrence 无需另一个局部选择即可省略 Header,并保留其现有 strict-Session Slot 生命周期。
+
+**把共享 Conversation store 移到 optional-Session Factory。** Header 与 Session body 共享一个 strict-Session handle。将该 handle 同时挂到 `session-maybe` Factory 和 `session` Header 会违反 one-handle-one-scope 规则。
+
+**在每个 parent 下分别注册同一装配。** 独立 registrations 会重复 definition 及其 child 声明。全局贡献方需要使用平行 child 名称,或造成声明冲突。
+
+**维护扁平共享 props 类型。** 这会重复 `children`、`store`、`inject`、`locale` 与 scope 字段中已有的事实,使声明与 Component props 可以发生漂移。
+
+**把 React node 或 render callback 作为业务 props 传递。** 这些值绕过 renderer 提供的 scope props、store 与 injection 装配、陈旧权限检查和局部 Component 类型检查。
+
+**让局部 slots 具备普通 Slot routing。** 普通 Slots 已经负责多贡献方 routing。局部 slot 表示一次 occurrence 的一个调用方选择。
+
+**向 `renderFactorySlot()` 传递 Session identity。** occurrence 像普通 Slot 一样继承渲染位置的 scope。第二个 identity 参数会产生两个可能不一致的权威;独立定址的 Session provider 属于另一项能力。
+
+## 影响
+
+功能包可以发布一份可复用 UI 装配,而消费方无需运行时导入其 Component。每个 occurrence 可以获得独立选择的局部 Components 与独占状态,同时保留全局普通 child 贡献以及现有 scope、locale、injection 和 store 规则。
+
+新增的 registry ledger 与 occurrence 记录增加了 renderer 复杂度。Factory definitions 必须全局唯一,局部 slots 有意只支持一个选中 Component,并且该 API 本身不会创建可独立定址的 Session scope。
+
+Factory 类型与运行时测试是可执行的兼容性记录。Slots 子系统参考以及 `ui-slots` 和 `ui-renderer` 包参考记录消费方 API。
diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
index 1a3ff86ba9..62399c273d 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99
-2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f
+2026-09-10-desktop-web-wrapper.md: 4f43cc650777596c807cf79463edc7a9131db44b
+2026-09-10-desktop-web-wrapper.zh.md: bc41b4f8eff1dca7fc3dd9b934dac257a9d10a6b
diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
index 445c574f86..4f43cc6507 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
@@ -14,15 +14,15 @@ Separate Desktop composition and request transport require their own configurati
The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
-The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, the runtime resolution, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
-The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
+The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host.
Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
-App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
+The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge.
-Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
+Shared `initProfile` creates missing profile files and preserves existing content. The Host computes its runtime resolution with `createRuntimeResolution` and installs it through `PluginPackages`, without writing fallback links. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism.
This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
@@ -36,7 +36,9 @@ Native directory selection in the local application uses a narrow preload IPC ca
**Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
-**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
+**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single owner of the runtime resolution can protect pnpm directories without maintaining release identity in the plugin profile.
+
+**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide.
**Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
@@ -44,6 +46,6 @@ Native directory selection in the local application uses a narrow preload IPC ca
Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails.
-User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the native recovery dialog rather than triggering stricter admission checks or automatic rollback.
+User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. The shared Web manager owns package-failure handling; Electron retains native recovery for fatal startup failures. The signed core runtime does not attest to user-installed plugin code.
Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
index a41ce3c2f1..bc41b4f8ef 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
@@ -14,15 +14,15 @@ Status: implemented
私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
-共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、runtime resolution、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。
独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
-App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
+共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。
-共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
+共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 通过 `createRuntimeResolution` 计算 runtime resolution,并通过 `PluginPackages` 安装它,不写入 fallback 链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。
本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
@@ -36,7 +36,9 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
**合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
-**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
+**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一 runtime resolution 归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
+
+**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。
**固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
@@ -44,6 +46,6 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
-用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
+用户选择的运行时选项、包来源和允许的生命周期脚本可能影响 Host 执行、加载第三方代码或造成启动失败。共享 Web 管理器负责包操作失败处理;Electron 为致命启动失败保留原生恢复。签名核心运行时不为用户安装的插件代码背书。
验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。
diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
index 3a9f8ed124..6953db3edf 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
-2026-09-10-image-offload-events.md: f540dc98cee8daf3e360b14234a2ac21d0f53aab
-2026-09-10-image-offload-events.zh.md: 868fca59ba5fc1941de752127f61b091aedb234f
+2026-09-10-image-offload-events.md: e80c4ae17b148d9eb46517b92111a8d768160def
+2026-09-10-image-offload-events.zh.md: 2aa2b8b1fbffd117b5d716aba28755a02a155bb8
diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
index f540dc98ce..e80c4ae17b 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
@@ -14,7 +14,7 @@ A request can exceed an image-capable route's byte or image-count budget. Recomp
Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged.
-The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
+The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including tool-role result content and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
The event has no `surfaceOp`. It neither creates a message nor replaces a message node. The plugin's pure projection validates all targets before Session commits the event and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
index 868fca59ba..2aa2b8b1fb 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
@@ -14,7 +14,7 @@ Status: implemented
摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。
-事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
+事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括 tool-role 结果内容和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
事件不带 `surfaceOp`,不创建消息,也不替换消息节点。插件的纯投影在 Session 提交事件前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
index 8c5140bfb7..de121c7c16 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
-2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44
-2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442
+2026-09-10-public-package-manifest.md: 2d35a3ad17c5aa62df4a5eccde351f325936a84f
+2026-09-10-public-package-manifest.zh.md: 7dc03e5160b3837ea07e19469b1d8a0a4f64ebe6
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
index 0e6c1650a6..2d35a3ad17 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
@@ -14,7 +14,7 @@ Plugin authors need npm identity, runtime requirements, and DSH declarations fro
Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
-The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
+The image packer owns `configTrees`, and the workspace catalog generator owns Session migration declarations. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
index 9b7d15f479..7dc03e5160 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
@@ -14,7 +14,7 @@ Status: implemented
运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
-镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
+镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
similarity index 59%
rename from .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
index 513a59328a..c27ef04676 100644
--- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
@@ -1,6 +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-16-known-site-link-marks.md
-2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
-2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
+2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9
+2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a
diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
new file mode 100644
index 0000000000..6cab947d7c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
@@ -0,0 +1,65 @@
+# Agent Note: Node Office conversion with independently packaged engines
+
+Status: implemented
+
+English | [中文](2026-09-11-node-office-kit.zh.md)
+
+## Problem
+
+Binary Office and OOXML files require document layout before they can be previewed. Conversion must stay on the device without opening an Office application or adding source bytes to a model conversation. Native engines need platform-specific distribution, while browser conversion duplicates font transport, worker ownership, and resource limits across the Host and Client.
+
+## Decision
+
+The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime.
+
+The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
+
+The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
+
+A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes.
+
+The kit owns default serif, sans-serif, and monospace preference groups, including Chinese text families. Missing Chinese glyphs in Western text try the corresponding common text families before searching the remaining catalog, which avoids choosing a handwriting face solely because its file sorts first. The provider's optional `fontFallbacks` replaces these ordered groups without duplicating their defaults. Preferences preserve exact installed fonts and retain other covering fonts as a last resort; they are not a font whitelist. The native adapter writes missing-family choices into its private VCL profile. Installed metric-compatible fonts can resolve before that table, and platform glyph fallback remains available. Native platform selection and whole collection imports require inspecting the fonts actually used in exported PDFs.
+
+DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine.
+
+The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
+
+Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
+
+The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion.
+
+Desktop installs the kit through its existing target-Node pnpm dependency installation and retains the complete dependency tree. Worker paths and executable permissions remain ordinary package files. The [Desktop build guide](../../../../apps/desktop/README.md) owns target selection and packaging; each signed application requires qualification on its target platform.
+
+The [Python executable distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) keeps the kit, target engine, and their dependency closure beside the executable. Its installed-wheel smoke relocates the payload, requires exactly the target backend, and converts DOCX once through that engine. Platform packaging and publication constraints belong to the [platform engine decision](2026-09-15-platform-office-engines.md).
+
+The notices gate permits only the exact API and engine package names at MPL-2.0 and continues to reject unrelated MPL or changed non-permissive terms. Each recipient must retain access to the kit’s corresponding LibreOffice source pin, patches, build instructions, and license notices; the engine packages retain their third-party notices. [MPL source availability](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) applies when distributing covered executables outside the organization.
+
+The [browser-only preview](../../../../packages/experimental/webworker-runtime/README.md) replaces the kit entry with an unavailable converter and excludes its engine dependency tree from the VFS image. Keeping the Host provider loadable preserves the ordinary Office error presentation without shipping Node Workers, native helpers, or WASM conversion resources to the browser.
+
+## Alternatives considered
+
+**Keep conversion in browser Workers.** This requires shipping the engine to the Client, browser font RPC, and shared-memory response headers. Node already owns authorized disk access and can serve the resulting PDF to every Client.
+
+**Use the installed soffice CLI or automate Microsoft Office.** Executable discovery and ambient versions weaken reproducibility; Office GUI automation additionally changes focus and requires application permissions. The distributed helper owns a fixed engine without requiring either application installation.
+
+**Load a native addon in the Host process.** A parser crash or synchronous stall would affect the Host. The separate native helper provides an independently terminable lifetime; its disk exchange also works with the WASM adapter.
+
+**Fall back after any native error.** Retrying a damaged package or failed document with another engine hides release defects, duplicates work, and makes output depend on failure timing. Platform selection requires the kit’s declared native target engine, or WASM when no native target is declared; it does not retry native failures with WASM.
+
+**Return a temporary PDF path, or rasterize pages to PNG.** The Client needs PDF bytes for its existing viewer and selectable text. Exposing temporary paths would add authorization and lease ownership; PNG would create another rendering pipeline and remove PDF controls.
+
+**Index every font for every render, or cache only family names.** Repeated indexing dominates small-document work, while family-only keys lose style and glyph distinctions. Shared metadata and complete per-request keys remove repeated parsing without retaining font buffers or introducing a filesystem watcher.
+
+**Compile at install time, download engines or fonts at runtime, or use an online converter.** These add toolchain or network requirements and may move private content off device. Prebuilt tarballs keep installation and conversion independent of those operations.
+
+**Keep an independent Office UI package.** Its reader, cache, and font notice have the same preview lifecycle and PDF presentation consumers. A separate plugin adds a package, boot wiring, and a Loader switch without an independently evolving UI responsibility. Host conversion and Remote authorization retain their separate plugins.
+
+**Remove the shared Client conversion cache.** Per-tab state cannot share pending conversion or retained PDFs across readers. A bounded cache avoids that repeated work while preserving per-reader cancellation and authorized version checks.
+
+**Add a model-facing rendering tool or durable preview events.** Preview provides no model input. Such a tool would require logged facts and both SDK projections and remains a separate consumer.
+
+## Consequences
+
+Native and WASM fidelity still depends on the build, source formatting, installed fonts, and platform font discovery. A missing glyph cannot be recovered without a covering font. Image resolution limits do not cap image decoding or total process memory. WASM retains bounded memory growth and checked stack space for large collections and CFF fonts; a fatal runtime abort prevents subsequent C++ cleanup calls. Macros and document-link updates are disabled by supported LOKit options and the pinned source patch; this does not constitute an OS sandbox.
+
+The [provider tests](../../../../packages/document/office-to-pdf/tests/provider.spec.ts), [Loader composition](../../../../packages/bundle/web-app/tests/document-preview.spec.ts), and [browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) own DSH lifecycle, authorization, and presentation evidence. Engine qualification additionally requires real DOC/DOCX/XLS/XLSX/PPT/PPTX conversion, external PDF text/font/page/image inspection, relocation and corrupted-package rejection, and same-input native/WASM performance samples. Mock helpers and microbenchmarks do not establish these outcomes. Each target's real builder and Desktop package need independent qualification; a successful local architecture does not prove the full matrix.
diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
new file mode 100644
index 0000000000..8ed1a81922
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
@@ -0,0 +1,65 @@
+# Agent Note: 独立打包引擎的 Node Office 转换
+
+Status: implemented
+
+[English](2026-09-11-node-office-kit.md) | 中文
+
+## 问题
+
+二进制 Office 和 OOXML 文件需要经过文档排版才能预览。转换必须留在设备上,不能打开 Office 应用,也不能将源字节加入模型对话。原生引擎需要按平台分发,而浏览器转换会在 Host 和 Client 两侧重复字体传输、worker 生命周期与资源限额管理。
+
+## 决策
+
+[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。
+
+[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
+
+服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
+
+converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
+
+kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中文正文字体。西文文本缺少中文字形时,先尝试同类的常用正文字体,再搜索其余字体目录,避免仅因文件排序靠前而选用手写体。provider 的可选 `fontFallbacks` 替换这些有序组,不重复维护默认值。优先规则保留已安装原字体的精确匹配,并以其他覆盖字体作为最后兜底;它们不是字体白名单。原生适配器将缺失字体的选择写入私有 VCL profile。已安装的度量兼容字体可能在查询该表前被选中,平台的字形回退仍然可用。原生平台的选择及完整字体集合的导入要求检查导出 PDF 实际使用的字体。
+
+DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。
+
+[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
+
+普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
+
+[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。
+
+Desktop 通过现有的目标 Node pnpm 依赖安装流程安装 kit,并保留完整依赖树。Worker 路径和可执行权限仍由普通包文件承载。[Desktop 构建指南](../../../../apps/desktop/README.zh.md) 负责目标选择与打包;每个签名应用仍需在目标平台验收。
+
+[Python 可执行分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)将 kit、目标引擎及其依赖闭包保留在可执行文件旁。安装后的 wheel 冒烟测试会迁移载荷,要求仅存在目标后端,并通过该引擎转换一次 DOCX。各平台的打包与发布限制由[平台引擎决策](2026-09-15-platform-office-engines.zh.md)说明。
+
+声明检查仅放行精确的 API 与引擎包名及 MPL-2.0 条款,继续拒绝无关 MPL 包或变更后的非宽松条款。每位接收者都必须保有访问 kit 对应 LibreOffice 源码版本、补丁、构建说明与许可证声明的权限;引擎包保留各自的第三方声明。向组织外部分发受覆盖的可执行文件时,须满足 [MPL 源码可用性要求](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)。
+
+[纯浏览器预览](../../../../packages/experimental/webworker-runtime/README.zh.md)用返回不可用错误的转换器替代 kit 入口,并从 VFS 镜像排除其引擎依赖树。保留 Host provider 的可加载性,可以沿用正常的 Office 错误展示,而无需向浏览器分发 Node Worker、原生辅助程序或 WASM 转换资源。
+
+## 考虑过的替代方案
+
+**保留浏览器 Worker 转换。** 这需要向 Client 分发引擎、提供浏览器字体 RPC 和配置共享内存响应头。Node 已经拥有授权磁盘访问能力,可以向所有 Client 提供转换后的 PDF。
+
+**使用已安装的 soffice CLI 或自动化 Microsoft Office。** 可执行文件发现和环境中的版本削弱可复现性;Office GUI 自动化还会改变焦点并需要应用权限。随包辅助进程拥有固定引擎,不要求安装这两类应用。
+
+**在 Host 进程中加载原生 addon。** 解析器崩溃或同步阻塞会影响 Host。独立的原生辅助进程具有可单独终止的生命周期,其磁盘交换方式也适用于 WASM 适配层。
+
+**任何原生错误都触发回退。** 使用另一引擎重试损坏的包或失败文档会隐藏发布缺陷、重复计算,并使输出取决于失败时机。平台选择要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM;原生失败后不会使用 WASM 重试。
+
+**返回临时 PDF 路径,或将页面栅格化为 PNG。** Client 需要 PDF 字节来使用现有查看器和可选择文本。暴露临时路径会增加授权与租约管理;PNG 则会建立另一条渲染管线并丢失 PDF 控件。
+
+**每次渲染重新索引所有字体,或只按字体族缓存。** 重复索引主导小文档成本,而仅以字体族为键会丢失样式和字形差异。共享元数据与完整请求键减少重复解析,无需保留字体缓冲区或引入文件系统监听器。
+
+**安装时编译、运行时下载引擎或字体,或使用在线转换器。** 这些方案增加工具链或网络要求,并可能将私有内容移出设备。预构建 tarball 使安装与转换不依赖这些操作。
+
+**保留独立的 Office UI 包。** 其读取器、缓存和字体提示共享预览生命周期与 PDF 展示消费者。独立插件增加包、启动接线和 Loader 开关,却没有独立演进的 UI 职责。Host 转换与 Remote 授权仍保留独立插件。
+
+**移除共享的 Client 转换缓存。** tab 内状态无法在读取方之间共享进行中的转换或已保留的 PDF。有界缓存减少这些重复工作,同时保留各读取方的取消和已授权版本检查。
+
+**添加面向模型的渲染工具或持久预览事件。** 预览不会提供模型输入。这样的工具需要日志事实及两套 SDK 投影,仍属于独立消费者。
+
+## 后果
+
+原生与 WASM 的保真度仍取决于构建、源文件格式、已安装字体及平台字体发现。没有覆盖字体就无法恢复缺失字形。图片分辨率限额不限制图片解码或总进程内存。WASM 为大型字体集合和 CFF 字体保留有界内存增长与受检查的栈空间;致命运行时中止会阻止后续 C++ 清理调用。宏与文档链接更新由实际支持的 LOKit 选项和固定源码补丁禁用;这不构成操作系统沙箱。
+
+[提供方测试](../../../../packages/document/office-to-pdf/tests/provider.spec.ts)、[Loader 组合](../../../../packages/bundle/web-app/tests/document-preview.spec.ts)和[浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts)负责 DSH 生命周期、授权与展示证据。引擎验收还需要真实 DOC/DOCX/XLS/XLSX/PPT/PPTX 转换、外部 PDF 文本、字体、页数与图片检查、迁移安装和损坏包拒绝,以及同输入的原生/WASM 性能样本。模拟辅助进程和微基准不能证明这些结果。各目标的真实构建机与 Desktop 安装包需要独立验收;一个本地架构成功不能证明整个矩阵。
diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml
new file mode 100644
index 0000000000..8f06bf7290
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md
+2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b
+2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8
diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md
new file mode 100644
index 0000000000..fbf30253bb
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md
@@ -0,0 +1,47 @@
+# Agent Note: Web app-owned routes are document-relative
+
+Status: implemented
+
+English | [中文](2026-09-14-web-document-relative-app-routes.zh.md)
+
+## Problem
+
+Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build.
+
+The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself.
+
+## Decision
+
+The served HTTP shell owns its document base. Its index carries exactly one ``, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: `/mount/` and `/mount/index.html` are the supported entries, and a document URL that later moves (an in-page `pushState`) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form.
+
+Browser references to those routes are relative to that document — `api/x`, `plugins/x` — while server route keys stay absolute pathnames: the RPC channel key `/api`, the `webServer` registration paths, and the response tables keyed by the combo route `/plugins/??...&rev=...`. A shared constant that names a key (`*_PATH`, `*_ENDPOINT`) is stripped at the boundary (`KEY.slice(1)`) before browser code uses it, and the browser form lives beside it as `*_ROUTE`.
+
+The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (`.../plugins/`) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix.
+
+The launch entry follows the same rule. `authenticatedUrl` adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to `./`, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged.
+
+Consumers that need an absolute URL resolve against `document.baseURI`: the Gateway when it constructs its WebSocket URL and selects `ws:` or `wss:`, and the Inspector when it locates its own bundle from the boot graph.
+
+Desktop is the other document owner. Its window loads `dsh-app://app/`, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's `streamBaseUrl` instead. Its resources live at that root, which is also why the served HTTP shell's `./` does not apply to it.
+
+## Alternatives considered
+
+**A configured base URL as the routing base (`publicUrl`).** Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input.
+
+**Inferring the mount from a forwarded-prefix header.** Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration.
+
+**A shared `Connection.resolveUrl` or routes base.** The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base.
+
+**An absolute `/` redirect with the proxy rewriting `Location`.** Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount.
+
+## Consequences
+
+One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one.
+
+Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own `*_ROUTE` constants; the static gate that keeps browser sources on this rule is recorded with those changes.
+
+## Testing
+
+The frontend-static real-composition test serves the index through the Loader and asserts one `` that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. `browser-auth.host.spec.ts` covers the `./` redirect and the authority- and mount-preserving `authenticatedUrl`; `frontend-static.spec.ts` and `apps/cli/tests/web-auth.e2e.ts` run the exchange through a real Loader composition and the built CLI.
+
+`apps/web/tests/public-mount.e2e.ts` is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a `pushState` to a deeper path leaves `document.baseURI` at the mount, and a document-relative `fetch('api/session/create')` still succeeds beneath it.
diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md
new file mode 100644
index 0000000000..c302ec88a4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md
@@ -0,0 +1,47 @@
+# Agent Note: Web 应用自有路由相对文档寻址
+
+Status: implemented
+
+[English](2026-09-14-web-document-relative-app-routes.md) | 中文
+
+## 问题
+
+浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/api/...` RPC、`/plugins/??...` 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 `/`。当一个监听端口位于剥离前缀的代理之后(例如 `https://host/tools/dsh/` 这样的挂载,把 `/tools/dsh/...` 转发为 `/...`),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。
+
+挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。
+
+## 决策
+
+所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 ``,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:`/mount/` 与 `/mount/index.html` 是受支持的入口,而文档 URL 之后移动(页面内 `pushState`)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。
+
+浏览器对这些路由的引用相对该文档——`api/x`、`plugins/x`——而服务端路由键保持绝对路径名:RPC channel 键 `/api`、`webServer` 注册路径,以及以 combo 路由 `/plugins/??...&rev=...` 为键的响应表。命名路由键的共享常量(`*_PATH`、`*_ENDPOINT`)在边界处被剥去前导斜杠(`KEY.slice(1)`)后浏览器代码才使用,浏览器形式以 `*_ROUTE` 与之并列。
+
+宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(`.../plugins/`)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。
+
+启动入口遵循同一规则。`authenticatedUrl` 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 `./`,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。
+
+需要绝对 URL 的消费方相对 `document.baseURI` 解析:Gateway 构造 WebSocket URL 并选择 `ws:` 或 `wss:` 时,以及 Inspector 从启动图定位自身 bundle 时。
+
+Desktop 是另一个文档所有者。它的窗口加载 `dsh-app://app/`:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 `streamBaseUrl` 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 `./` 不适用于它的原因。
+
+## 曾考虑的替代方案
+
+**把配置 base URL 当作路由 base(`publicUrl`)。** 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。
+
+**从转发前缀请求头推断挂载点。** 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。
+
+**共享 `Connection.resolveUrl` 或 routes base。** HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。
+
+**保留绝对 `/` 重定向并让代理改写 `Location`。** 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。
+
+## 后果
+
+一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。
+
+功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 `*_ROUTE` 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。
+
+## 测试
+
+frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 `` 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。`browser-auth.host.spec.ts` 覆盖 `./` 重定向与保留 authority 和挂载的 `authenticatedUrl`;`frontend-static.spec.ts` 与 `apps/cli/tests/web-auth.e2e.ts` 分别经真实 Loader 组合与构建后的 CLI 运行该交换。
+
+`apps/web/tests/public-mount.e2e.ts` 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,`pushState` 到更深路径后 `document.baseURI` 仍停在挂载点,而文档相对的 `fetch('api/session/create')` 在其下依然成功。
diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml
index 6cfc0f2673..2f64b37f55 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
-2026-09-15-bounded-office-conversion.md: 75e61ce0c49dafac68d4cf3a653d13cbee096e4c
-2026-09-15-bounded-office-conversion.zh.md: a2accf02ec058fa90b83e36bc761a2b7637eaef9
+2026-09-15-bounded-office-conversion.md: b15aa5421c575451ddb4a7bf91169bcb2fa38d3c
+2026-09-15-bounded-office-conversion.zh.md: f23c38096764cf32207125cc0c3ebbdf1c04d009
diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
index 75e61ce0c4..b15aa5421c 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
@@ -10,7 +10,7 @@ Office preview and explicit document inspection can request the same conversion.
## Decision
-The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
+The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
@@ -34,4 +34,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b
The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
+Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output.
+
Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.
+
+## Related
+
+The [background scheduling proposal](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.md) questions the need for speculative priority without a production caller; this note remains the authority for the implemented behavior.
diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md
index a2accf02ec..f23c380967 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md
@@ -10,7 +10,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
## 决策
-`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
+`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
@@ -34,4 +34,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
+Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。
+
受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。
+
+## 相关记录
+
+[后台调度提案](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md) 质疑没有生产调用方时保留推测性优先级的必要性;本记录仍定义已实现的行为。
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
index 1bcfbf5d4a..0a33e19e2c 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-client-session-references.md
-2026-09-15-client-session-references.md: 2d727809b237ca919296dac82a3b201ba2dd8fbf
-2026-09-15-client-session-references.zh.md: 34fa6526a57f43fd7636c68c7ed72bb982a129f2
+2026-09-15-client-session-references.md: 162ef08875e5b0591b3b4099c9bdcadb18e7d175
+2026-09-15-client-session-references.zh.md: 3af8932f7c002b3b63de21c689ae91e3017a164e
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
index 2d727809b2..162ef08875 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
@@ -73,7 +73,7 @@ The Controller owns source counts alongside each live generation's references. F
Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them.
-The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
+The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` contains Host summaries, projected child-catalog rows, and fallback rows for retained subagents with a known direct-parent address. Acquisition publishes a missing subagent row synchronously so Provider discovery does not depend on parent-catalog arrival; title projections and reference-source counts continue to update that row. Ordinary retained Sessions do not synthesize fallback rows. `ids` remains the Host-list membership and ordering. Consumers of an unlisted generation use its binding and `retainInfo` directly. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum.
@@ -101,7 +101,7 @@ Source counts remain authoritative in `SessionListState.byId[id].retainedBy`; UI
Pending domains retain `SessionPendingInteractionMap`, request identities, precedence, publication disposers, and teardown delegation. The unified status includes the same effective request object; it does not copy requests or create a second pending registry. Workspace status indicators and Conversation composer selection read `useSessionStatus` instead of separate `useSessionPendingInteraction` and `useCompletedSessionIds` hooks.
-Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Catalog snapshots establish initial and reconnect baselines. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
+Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Host-list rows (`ids`) establish initial and reconnect running baselines; synthetic catalog and retained rows preserve independently observed status. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
- An initial idle baseline does not create a completion reminder.
- Observing running clears an earlier reminder and records the running baseline.
@@ -141,7 +141,7 @@ Scoped business objects capture their binding before awaiting work. They do not
### Main-area navigation and presentation
-`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
+`uiWorkspace.openSession`, `openWorkspace`, and `startSession` are the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. Sidebar `forkSession` creates and renames the child without retaining it or changing selection. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup.
@@ -160,7 +160,7 @@ Source metadata does not change when DOM focus moves or when a global panel hide
Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler.
-Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
+Workspace connection preserves its navigation guards and panel-switch invalidation. Sidebar fork does not supersede an in-flight navigation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
### Presets and creation flows
@@ -221,6 +221,6 @@ Consumers must keep references until their real completion point; an unreleased
Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values.
-Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation.
+Every public `retain`, including the temporary reference used by ordinary Session rename, starts the generation's shared initial history opening. Fork-title assignment instead sends rename directly and applies its returned projection without a Client reference or history I/O. A metadata-only fork operation therefore cannot create a temporary list row or switch the main view.
The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator.
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
index 34fa6526a5..3af8932f7c 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
@@ -73,7 +73,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源
每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。
-即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
+即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 包含 Host 摘要、子会话目录投影行,以及已保留且具有明确直接父子地址的 subagent 兜底行。获取引用时同步发布缺失的 subagent 行,使 Provider 发现不依赖父目录到达;标题投影和引用来源计数继续更新该行。普通被保留 Session 不合成兜底行。`ids` 仍表示 Host 列表成员关系与顺序。未列出 generation 的使用方直接读取其 binding 与 `retainInfo`。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。
@@ -101,7 +101,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源
待处理领域保留 `SessionPendingInteractionMap`、请求身份、优先级、发布清理函数与卸载委托。统一状态包含同一个有效请求对象,不复制请求,也不创建第二套待处理注册表。Workspace 状态指示器与 Conversation composer 选择读取 `useSessionStatus`,不再分别读取 `useSessionPendingInteraction` 和 `useCompletedSessionIds` 钩子。
-完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。目录快照建立初始与重连基线。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
+完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。Host 列表行(`ids`)建立初始与重连的 running 基线;目录合成行与 retained 行保留独立观察到的状态。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
- 初始 idle 基线不产生完成提醒。
- 观察到 running 时清除旧提醒,并记录运行基线。
@@ -141,7 +141,7 @@ Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或
### 主区域导航与展示
-`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
+`uiWorkspace.openSession`、`openWorkspace` 和 `startSession` 是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。侧边栏 `forkSession` 创建并重命名子会话,不 retain 子会话,也不改变选择。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。
@@ -160,7 +160,7 @@ DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据
Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。
-Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
+Workspace 连接保持既有导航检查与面板切换失效规则。侧边栏 fork 不替代尚未完成的导航。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
### 预设与创建流程
@@ -221,6 +221,6 @@ Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Con
最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。
-每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。
+每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括普通 Session 重命名所用的临时引用。Fork 标题设置直接发送 rename 并应用返回的投影,不获取 Client 引用,也不读取历史。因此,仅修改元数据的 fork 操作不会创建列表临时行或切换主视图。
目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。
diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
index 859e17061e..3c89776191 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
-2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
-2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e
+2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27
+2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19
diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
index 63ec81991f..94ead425f5 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
@@ -10,13 +10,13 @@ A recovery document depends on the renderer and preload whose failure can preven
## Decision
-Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
+Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
-The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
-The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
+The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
-This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes.
+This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared plugin management and native profile preparation.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
index 05dbad8006..2138fb1140 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
@@ -10,13 +10,13 @@ Status: implemented
## Decision
-Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
-Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
+Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
-这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
+本决策取代[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页面和重置行为;其立即显示与直接启动 Host 的理由仍有效。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享插件管理和原生 profile 准备。
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.i18n.yaml
new file mode 100644
index 0000000000..d44576c5b1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.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-15-first-class-tool-role-messages.md
+2026-09-15-first-class-tool-role-messages.md: 3749ad698890073634b1753d3370f9c9dc9a2e18
+2026-09-15-first-class-tool-role-messages.zh.md: c97d84a7b3ceaee17ebb6fe828bd739dba5fb0ea
diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md
new file mode 100644
index 0000000000..3749ad6988
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md
@@ -0,0 +1,28 @@
+# Agent Note: First-class tool-role messages
+
+Status: implemented
+
+English | [中文](2026-09-15-first-class-tool-role-messages.zh.md)
+
+## Problem
+
+The released Session format stores tool results inside a user-role `tool-result` content block, while the model conversation and provider protocols treat a tool result as a separate role. Keeping both descriptions active makes validation, replay, and adapter projections disagree.
+
+## Decision
+
+Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge.
+
+Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. Opaque retention avoids inventing a shared field owner when the wrapper disappears. It retains duplicate values from distinct owners, and plugin consumers must use the V4 names. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules.
+
+The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers.
+
+## Alternatives considered
+
+- **Keep wrapper-shaped V4 rows** — this preserves the released payload but leaves the canonical role and provider wire formats inconsistent.
+- **Accept both wrapper and native V4 rows** — dual current representations make encoding, decoding, recovery, and replay depend on which path produced the artifact.
+- **Flatten historical nested results** — leaf text does not retain nested call IDs or error status, and providers did not interpret nested content uniformly. Unsupported migration preserves the original data until a lossless conversion is available.
+- **Keep role extensions open** — an extension could reach a model request without a Session event and could not be reconstructed on replay.
+
+## Consequences
+
+The adjacent migration changes the logical message role without changing message identity, tool-call identity, or result content. Native V4 semantic failures are hard admission failures; recoverable decoding still applies to released V3 canonical tails. Adapters map the first-class role to their provider-specific tool-result representation. The duplicated message `toolCallId` and source `callId` remain as an explicit equality-checked provider field and source-identity field pair.
diff --git a/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md
new file mode 100644
index 0000000000..c97d84a7b3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.zh.md
@@ -0,0 +1,28 @@
+# Agent Note:一等 tool-role 消息
+
+Status: implemented
+
+[English](2026-09-15-first-class-tool-role-messages.md) | 中文
+
+## 问题
+
+已发布的 Session 格式把工具结果存储在 user-role 的 `tool-result` 内容块中,而模型对话与提供方协议把工具结果视为独立角色。两种描述同时存在会使校验、回放和适配器投影互相不一致。
+
+## 决策
+
+Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。
+
+移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。不透明保留避免在 wrapper 消失时虚构共同字段 owner。不同 owner 的重复值仍分别保留,插件消费者必须使用 V4 名称。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。
+
+对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。
+
+## 曾考虑的替代方案
+
+- **保留包装形式的 V4 行**——保留已发布载荷,但规范角色与提供方 wire 格式仍不一致。
+- **同时接受包装和原生 V4 行**——两种当前表示会让编码、解码、恢复和回放依赖于产出 artifact 的路径。
+- **展平历史嵌套结果**——叶子文本无法保留嵌套调用 ID 或错误状态,而且提供方对嵌套内容的解释并不一致。拒绝不支持的迁移会保留原始数据,等待能够保留全部信息的转换。
+- **保持角色扩展开放**——扩展角色可能在没有 Session 事件的情况下到达模型请求,也无法在回放时重建。
+
+## 后果
+
+相邻迁移会改变逻辑消息角色,但不改变消息身份、工具调用身份或结果内容。原生 V4 语义失败属于硬准入失败;可恢复解码仍适用于已发布 V3 的规范尾部。适配器把一等角色映射为各提供方自己的工具结果表示。消息 `toolCallId` 与来源 `callId` 的重复仍作为一对显式且经过相等性校验的提供方字段与来源字段保留。
diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml
new file mode 100644
index 0000000000..ecb789a14d
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.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-16-desktop-cos-upload-transport.md
+2026-09-16-desktop-cos-upload-transport.md: 84504b32fd02d1fbaf8276b4f1baf7d85f7dcbd5
+2026-09-16-desktop-cos-upload-transport.zh.md: 2a2080f95b39ab433b4ccb3bec60e5ba61f52dc9
diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md
new file mode 100644
index 0000000000..84504b32fd
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md
@@ -0,0 +1,43 @@
+# Agent Note: Upload Desktop releases through the Tencent COS SDK
+
+Status: implemented
+
+English | [中文](2026-09-16-desktop-cos-upload-transport.zh.md)
+
+## Problem
+
+Release objects were uploaded with the AWS S3 client pointed at the Tencent COS endpoint. That client's default checksum configuration can send a streamed request trailer under `Content-Encoding: aws-chunked`, a marker S3 removes before storing but the compatibility path can retain as object metadata. A user-visible application download failed with an HTTP/2 `RST_STREAM` immediately after response headers while an adjacent download on the same connection completed, and objects reported to carry the marker failed segmented downloads. Neither observation identifies an exclusive cause, but the upload transport is the part this repository owns, and a CDN serving metadata no origin request produced is a defect worth removing before chasing it further.
+
+## Decision
+
+[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts) builds every Desktop COS client from the official `cos-nodejs-sdk-v5` package: HTTPS, no keep-alive, no redirect following, no backup-host switching, no clock-offset correction, and a 900-second inactivity timeout. [upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts) and [installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts) obtain their client from that factory, and `@aws-sdk/client-s3` is no longer a Desktop dependency.
+
+Every uploaded object — binary, blockmap, and YAML feed, including the small string manifests — travels as one `putObject` whose body is a stream carrying an explicit `ContentLength` and a precomputed `Content-MD5`. The stream is what makes the write unrepeatable: the SDK repeats a request only while the body lacks `pipe`, so a streamed PUT is attempted once and neither uploader adds a retry of its own. The SDK also injects an empty `Cache-Control` header when the caller names none; the factory removes that header so the release uploader still leaves cache policy to deployment infrastructure.
+
+The qualification transport keeps the same store interface and namespace check as before. It reads objects through `getObject` with a `Writable` output that hashes received bytes, so an object is never held in memory, and a confirmed `NoSuchKey` is the only absence result; every other status, and a transfer that ends before its declared length, fails the operation.
+
+## Testing
+
+[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts) redirects a real SDK instance's `before-send` URL to a per-test loopback origin that records the received bytes, so the transport's serialization — not a mock of it — is what the tests observe. [desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts) and [installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts) assert exact body bytes, `Content-Length`, `Content-MD5`, the absence of transfer and content encodings, the signed `x-cos-forbid-overwrite` header, one request per object on an HTTP 500 and on a dropped connection, read 404/403/truncation handling, and that retained records exclude credentials and raw server messages.
+
+[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts) verifies total deadlines across retries, active response streams, cancellation of unconfirmed PUTs, and closure without interfering with another operation. Virtual deadline timers are combined with real socket and stream observations.
+
+## Alternatives considered
+
+**Keep the S3 client with `requestChecksumCalculation: WHEN_REQUIRED` on both paths.** The qualification transport already used that setting, and it suppresses the trailer. It still writes through a compatibility layer for a product whose vendor ships a supported client, and the release path would depend on a configuration staying correct rather than on an encoding never being produced. The official SDK removes the layer instead of tuning it.
+
+**Send a buffer body for the small YAML manifests.** A buffer is cheaper for a few hundred bytes and needs no stream handling. It also re-enables the SDK's fixed four-attempt retry, so an uncertain feed write could silently repeat a mutable object. Uniform streamed PUTs keep one write path with one guarantee.
+
+**Use the SDK's `uploadFile` queue or multipart upload.** Those paths parallelize large objects and add progress reporting. They also split one object into parts with per-part retries and upload state, widening the repeat surface for an artifact that must be written exactly once. The release uploader sends one request per object.
+
+**Replace only the release uploader and leave the qualification transport on S3.** The qualification transport writes to the same bucket family, and leaving it behind would keep the compatibility path alive for the objects most likely to be compared against production behavior.
+
+## Consequences
+
+Object writes cannot repeat, which is the property the incident needed, and the transport no longer depends on S3-compatibility behavior. In exchange, each object is one request: a large installer is not parallelized and has no progress reporting, matching the single-request behavior it replaces. Qualification version queries share a 30-second total deadline across all SDK attempts; object reads and PUTs have a 15-minute total deadline. Each operation owns its client and abort signal. [cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts) passes that signal to native HTTP requests through the SDK transport and waits for their close events before returning, including on timeout. Continuous response data does not extend the deadline, and an expired signal prevents later retries from opening connections. The release uploader retains its separate inactivity timeout.
+
+The SDK's dependency tree is a fork of the retired `request` package, pulling in older `http-signature`, `tough-cookie`, and `form-data` releases; the lockfile supply-chain check accepts it, and the alternative was hand-writing COS request signing.
+
+Existing objects that already carry the retained encoding marker, and their cached copies, are unaffected by this change. Removing or re-uploading them, and any CDN cache purge, remain separate operator actions; this note records no cloud operation.
+
+The release decision that an uncertain write must stay one inspectable attempt lives in the [packaging and updates decision](2026-08-25-electron-desktop-packaging-and-updates.md); this note changes the transport that implements it.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md
new file mode 100644
index 0000000000..2a2080f95b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md
@@ -0,0 +1,43 @@
+# Agent Note: 通过腾讯 COS SDK 上传 Desktop 发布产物
+
+Status: implemented
+
+[English](2026-09-16-desktop-cos-upload-transport.md) | 中文
+
+## 问题
+
+发布对象此前通过指向腾讯 COS 端点的 AWS S3 客户端上传。该客户端默认的校验和配置可以用 `Content-Encoding: aws-chunked` 发送流式请求尾帧;S3 在存储前会移除该标记,而兼容路径可能把它保留为对象元数据。一次面向用户的应用下载在收到响应头后立即以 HTTP/2 `RST_STREAM` 失败,而同一连接上的相邻下载正常完成;被报告带有该标记的对象分段下载失败。这两项观察都不能证明唯一原因,但上传传输是本仓库自己拥有的部分,而 CDN 提供任何源请求都未产生的元数据本身就是缺陷,值得在继续追查之前先消除。
+
+## 决策
+
+[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts)使用官方 `cos-nodejs-sdk-v5` 包构造每个 Desktop COS 客户端:HTTPS、不保持连接、不跟随重定向、不切换备用域名、不做时钟偏移校正,无活动超时为 900 秒。[upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts)与[installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts)都从该工厂获取客户端,`@aws-sdk/client-s3` 不再是 Desktop 依赖。
+
+所有上传对象——安装包、blockmap 和 YAML 清单,包括很小的字符串清单——都以一次 `putObject` 发送,其请求体是流,并携带显式 `ContentLength` 与预先算好的 `Content-MD5`。正是流使写入不可重复:SDK 只在请求体没有 `pipe` 时才重发请求,因此流式 PUT 只会尝试一次,两个上传器自身也不再添加重试。当调用方未指定时,SDK 还会注入空的 `Cache-Control` 头;工厂会移除该头,因此发布上传器仍然把缓存策略留给部署基础设施。
+
+qualification 传输保持原有 store 接口与命名空间检查不变。它通过 `getObject` 配合一个对已接收字节计算哈希的 `Writable` 输出读取对象,因此对象从不整体驻留内存;只有确认的 `NoSuchKey` 表示对象不存在,其他任何状态码,以及早于声明长度结束的传输,都会让操作失败。
+
+## 测试
+
+[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts)把真实 SDK 实例 `before-send` 中的 URL 重定向到每个测试独立的 loopback 源,并记录实际收到的字节,因此测试观察的是传输的序列化结果,而不是它的 mock。[desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts)与[installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts)断言确切的请求体字节、`Content-Length`、`Content-MD5`、不存在传输编码与内容编码、已签名的 `x-cos-forbid-overwrite` 头、HTTP 500 与连接中断时每个对象只发一次请求、读取 404/403/截断的处理,以及保留记录不包含凭据与原始服务端消息。
+
+[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts)验证跨重试的总截止时间、持续返回数据的响应流、未确认 PUT 的取消,以及关闭请求不影响其他操作。测试组合虚拟截止时间计时器与真实 socket 和流观测。
+
+## 考虑过的替代方案
+
+**保留 S3 客户端并在两条路径上都设置 `requestChecksumCalculation: WHEN_REQUIRED`。** qualification 传输原本已使用该设置,它能抑制尾帧。但它仍然通过兼容层写入一个厂商已提供受支持客户端的产品,发布路径也会依赖于某项配置始终正确,而不是依赖编码根本不会产生。官方 SDK 消除的是这一层,而不是调整它。
+
+**对小体积 YAML 清单改用 Buffer 请求体。** 对几百字节而言 Buffer 更省事,也不需要流处理。但它会重新启用 SDK 固定的四次重试,使一次结果不确定的清单写入可能静默重复一个可变对象。统一的流式 PUT 保持单一写入路径和单一保证。
+
+**使用 SDK 的 `uploadFile` 队列或分块上传。** 这些路径可并行处理大对象并提供进度上报。它们也会把单个对象拆成多个分块,带来分块级重试与上传状态,扩大一个必须恰好写入一次的产物的重复面。发布上传器对每个对象只发一次请求。
+
+**只替换发布上传器,qualification 传输继续使用 S3。** qualification 传输写入同一系列 bucket,若把它留下,兼容路径就会继续存在于最可能用于与生产行为对照的对象上。
+
+## 结果
+
+对象写入不可重复,这正是本次故障所需的性质,传输也不再依赖 S3 兼容行为。代价是每个对象只有一次请求:超大安装包不会并行,也没有进度上报,与它替换掉的单请求行为一致。qualification 版本查询的所有 SDK 尝试共享 30 秒总截止时间;对象读取与 PUT 的总截止时间为 15 分钟。每次操作独立拥有客户端与取消信号。[cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts)通过 SDK 传输将信号传给底层 HTTP 请求,并在返回前等待请求的关闭事件,超时也不例外。持续返回数据不会延长截止时间,过期信号会阻止后续重试建立连接。发布上传器保留独立的无活动超时。
+
+该 SDK 的依赖树源自已停止维护的 `request` 包,会引入较旧的 `http-signature`、`tough-cookie` 和 `form-data` 版本;lockfile 的供应链检查接受它,替代方案是自行实现 COS 请求签名。
+
+已经带有该保留编码标记的既有对象及其缓存副本不受本次改动影响。删除或重新上传它们,以及任何 CDN 缓存刷新,仍是单独的操作动作;本记录不包含任何云端操作。
+
+「结果不确定的写入必须保持为一次可检查的尝试」这一发布决策由[打包与更新决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责;本记录改变的是实现它的传输。
diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml
index 217af9cbb3..65566f68ce 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md
-2026-09-16-experimental-voice-input.md: 06ecb9ff9e83b82147ca2d3ae1386d7ec9c6d382
-2026-09-16-experimental-voice-input.zh.md: f1ad2770f768316f533a081b9dd06ace33e959ec
+2026-09-16-experimental-voice-input.md: 30c81ab233f27242dcf1fe2ef89bbb86542f9c04
+2026-09-16-experimental-voice-input.zh.md: 76aa8d6fc4bea32f194b20d05db4bf49af0be313
diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md
index 06ecb9ff9e..30c81ab233 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.md
@@ -18,7 +18,7 @@ Recordings and transcripts remain transient until the user submits ordinary text
SenseVoice ONNX and Silero VAD run in one managed CPU subprocess. The native sherpa-onnx package includes ONNX Runtime; explicit preparation only downloads and verifies models; subsequent calls reuse the warm model until the configured idle deadline. Every activation verifies cached assets and restores complete caches to standby without starting the worker. Readiness therefore follows disk contents rather than the lifetime of a Provider instance. Recordings wait in the bounded queue during wake-up. The worker checks readiness at execution, so queued work cannot implicitly download resources invalidated by an earlier cache inspection. Inspection uses the same owned task as preparation, so cancellation and disposal join it. Disabling the provider joins its work and process exit while retaining reusable disk assets. Native browser recording and resampling avoid an additional audio conversion executable. The authenticated private loopback protocol carries complete PCM recordings, allowing cancellation to terminate the worker without sharing a long-running inference with another request.
-The Host owns preparation independently of the view that started it. An ordered step list survives view remounts and records completion only as execution advances. Recognition weights, VAD and final verification have separate operations; downloads use pinned byte counts, while other work reports elapsed time. Preparation status and progress stay in bundle details through `plugins.bundle.config`, keyed by package name. The generic `plugins.bundle.activation` slot carries explicit enablement intent and detail navigation; the voice occupant waits for Host cache inspection and offers installation guidance only for unprepared local models. Enabling an already prepared provider does not prompt or download. A single composer activity Slot expands the voice controls while preserving the draft editor and submit action. The context meter precedes the idle microphone; recording and transcription hide the meter along with ordinary toolbar controls until the activity closes. Shared primitives provide buttons, disclosure and status markers; only measured waveform drawing belongs to the voice UI. Recordings start on click and transcribe on Stop, with feedback inside the activity. Persistent preferences remain in plugin settings.
+The Host owns preparation independently of the view that started it. An ordered step list survives view remounts and records completion only as execution advances. Recognition weights, VAD and final verification have separate operations; downloads use pinned byte counts, while other work reports elapsed time. Preparation status and progress stay in bundle details through `plugins.bundle.config`, keyed by package name. The generic `plugins.bundle.activation` slot carries explicit enablement intent and detail navigation; the voice occupant waits for Host cache inspection and offers installation guidance only for unprepared local models. Enabling an already prepared provider does not prompt or download. A single composer activity Slot expands the voice controls while preserving the draft editor and submit action. The context meter sits below the composer; recording and transcription hide the meter along with ordinary toolbar controls until the activity closes. Shared primitives provide buttons, disclosure and status markers; only measured waveform drawing belongs to the voice UI. Recordings start on click and transcribe on Stop, with feedback inside the activity. Persistent preferences remain in plugin settings.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md
index f1ad2770f7..76aa8d6fc4 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-experimental-voice-input.zh.md
@@ -18,7 +18,7 @@ Provider id 选择确切的注册。解析捕获实例,注销先拒绝新任
SenseVoice ONNX 与 Silero VAD 在一个托管 CPU 子进程中运行。原生 sherpa-onnx 包包含 ONNX Runtime;显式准备只下载并校验模型;后续调用复用预热模型,直到配置的空闲截止时间。每次启用都会校验缓存资源,完整缓存恢复为待唤醒状态,不启动工作进程。就绪状态因此取决于磁盘内容,而非 Provider 实例的存活时间。录音在唤醒期间进入有界队列等待。工作进程在执行时检查就绪状态,因此较早的缓存检查使资源失效后,排队任务也不能隐式下载资源。检查复用准备任务的生命周期,取消与释放都会等待检查结束。禁用 Provider 等待其任务和进程退出,同时保留可复用的磁盘资源。浏览器原生录音与重采样避免增加音频转换可执行文件。带认证的私有环回协议传输完整 PCM 录音,取消时可以终止工作进程,而不与另一请求共享长时间推理。
-Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和提交按钮。空闲时上下文用量按钮位于麦克风之前;录音和转写期间,用量按钮与普通工具栏控件一起隐藏,关闭活动后恢复。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。
+Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和提交按钮。空闲时上下文用量按钮位于输入框下方;录音和转写期间,用量按钮与普通工具栏控件一起隐藏,关闭活动后恢复。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。
## 考虑的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
index d1c3e82cbb..8c89aed087 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
-2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4
-2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc
+2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028
+2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
index fb330bee44..475580af48 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
@@ -10,21 +10,23 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
## Decision
-**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin.
+**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-18-localized-package-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text.
+
+The page passes `view: 'page'` for each form. Official plugin cards and details also use `view: 'summary'`; a row's detail page uses it only when the plugin has no display description. Bundle configuration renders only `page`. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. Sidebar navigation and installation-request ownership follow the [sidebar management decision](2026-09-09-plugin-management-in-the-web-sidebar.md).
**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema.
-**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence.
+**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence.
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
-**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example.
+**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
## Consequences
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
-- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page.
-- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded.
+- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page.
+- Settings lists the inventory only; the Settings browser goldens cover that read-only section.
## Alternatives considered
@@ -32,8 +34,8 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor.
-**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page.
+**Keeping the cards in Settings and linking to them from the page.** This separates a plugin's description and configuration across different pages.
## Testing
-`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group.
+`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page, including description precedence over its summary; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the Official group.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
index 65c1f5a8bf..22d0aa4ff1 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
@@ -1,6 +1,6 @@
-# Agent Note:插件页上的插件配置
+# Agent Note: 插件页上的插件配置
-状态:已实现
+Status: implemented
[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文
@@ -10,21 +10,23 @@
## 决策
-**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。
+**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-18-localized-package-metadata.zh.md);slot 键和技术身份不随展示文本改变。
+
+页面通过 `view: 'page'` 渲染每个表单。官方插件卡片与详情还使用 `view: 'summary'`;行详情页只在插件没有展示描述时使用它。组合包配置仅渲染 `page`。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。侧栏导航与安装请求归属遵循[侧栏管理决策](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)。
**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。
-**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。
+**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
-**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。
+**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
## 后果
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
-- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。
-- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。
+- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。
+- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。
## 考虑过的替代方案
@@ -32,8 +34,8 @@
**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。
-**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。
+**把卡片留在设置里、从插件页链接过去。** 这会把一个插件的描述和配置分散到不同页面。
## 测试
-`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。
+`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面,包括描述优先于其摘要的规则;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录官方分组。
diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml
new file mode 100644
index 0000000000..758bca04da
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.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-16-size-neutral-product-icon-weights.md
+2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1
+2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc
diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md
new file mode 100644
index 0000000000..3850fcbddf
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md
@@ -0,0 +1,29 @@
+# Agent Note: Size-neutral product icon weights
+
+Status: implemented
+
+English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md)
+
+## Problem
+
+Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features.
+
+## Decision
+
+Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source.
+
+`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export.
+
+Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode.
+
+## Alternatives considered
+
+**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry.
+
+**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable.
+
+**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library.
+
+## Consequences
+
+The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md
new file mode 100644
index 0000000000..84031677e9
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 尺寸无关的产品图标线重
+
+Status: implemented
+
+[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文
+
+## 问题
+
+共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。
+
+## 决策
+
+每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。
+
+`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。
+
+Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。
+
+## 考虑过的替代方案
+
+**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。
+
+**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。
+
+**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。
+
+## 后果
+
+图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml
new file mode 100644
index 0000000000..db3f3c7cb5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.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-17-developer-session-changes.md
+2026-09-17-developer-session-changes.md: 0545dfa4344f8ed87fe6343e13901647a5ccdac4
+2026-09-17-developer-session-changes.zh.md: 81c53115aaf4447dac358febcbd1310bb2a36cb2
diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md
new file mode 100644
index 0000000000..0545dfa434
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md
@@ -0,0 +1,35 @@
+# Agent Note: Developer messages for incremental session changes
+
+Status: implemented
+
+English | [中文](2026-09-17-developer-session-changes.zh.md)
+
+## Problem
+
+Dynamic tool changes need durable, ordered records before providers can preserve the cached conversation prefix while changing available tools. Encoding these changes as free-form reminders would lose their structured tool identity and couple session state to prompt wording.
+
+## Decision
+
+`DeveloperMessage` records admitted model-visible session changes in conversation order. Additions store only `toolName`; the containing event's `headerSeq` selects an earlier full `request/header` with exactly one schema for each added name. One event binds all its additions to one header revision. Additions replace the active same-name definition; removals identify the name without duplicating its schema. The header reference is absent when the message contains no additions. Same-name replacement, restart, fork, and surface replacement retain historical definition identity without consulting the current registry or latest request header. Intermediate registry changes that no admitted request exposes do not require developer records.
+
+`developer/message` carries the message and its admitting turn/step coordinates. Native readers and Session admission reject missing, forward, unknown, non-header, and ambiguous schema references, incomplete referenced definitions, and reject retired inline definitions while preserving unrelated JSON fields. `sourceEventSeqs` remains independent derivation metadata. A replacement retains its selected header and cites the shadowed surface nodes; combining additions from different header revisions requires separate events. Forks retain the referenced prefix, and compaction does not delete header events. Future cardinality-changing migrations must remap `headerSeq` as a same-artifact event reference. Empty developer content retains its surface position without adding a model message or shadowing the protected system head. The tools package owns the `tool-registry` source kind under the [producer-owned source rules](2026-09-09-producer-owned-message-sources.md).
+
+`ToolAdditionBlock.tool` is declared as optional `never` with `@persistenceReserved`. The schema history therefore records the rejected field: permitting any value later changes an existing value type and requires a format bump, while unrelated optional metadata remains additive.
+
+`ToolSchema.deferLoading` requests deferred definition loading independently of tool-addition history. Tool construction, registry projection, and prompt assembly preserve the marker. Dynamic addition and definition loading are separate facts; a predeclared deferred definition need not acquire a fabricated addition record.
+
+The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations.
+
+The producer requirement has one scoped exception: finalized Session V4 reserves the persisted representation needed by issue #4146. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together.
+
+## Alternatives considered
+
+**Use text reminders as developer content.** The format represents structured session changes; model instructions belong in system messages. Future developer content is unspecified rather than constrained to reminders.
+
+**Duplicate definitions in each addition.** Full request headers already preserve the exact schemas. A historical reference avoids a second persisted copy while retaining same-name replacement identity. Binding by the latest header or registry instead would reinterpret older additions.
+
+**Treat deferred loading as proof of a tool-addition record.** That would prevent predeclared deferred tools. The schema flag describes loading, while the block records a dynamic addition.
+
+## Consequences
+
+Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md
new file mode 100644
index 0000000000..81c53115aa
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md
@@ -0,0 +1,35 @@
+# Agent Note:使用 developer 消息记录增量 Session 变更
+
+Status: implemented
+
+[English](2026-09-17-developer-session-changes.md) | 中文
+
+## Problem
+
+动态工具变更需要持久且有序的记录,提供方才能在改变可用工具时保留对话的缓存前缀。将这些变更编码为自由文本提醒会丢失结构化的工具身份,并将 Session 状态与提示词措辞耦合。
+
+## Decision
+
+`DeveloperMessage` 按对话顺序记录已接纳且模型可见的 Session 变更。添加块仅存储 `toolName`;所在事件的 `headerSeq` 选择更早的完整 `request/header`,其中每个添加名称必须恰好对应一个模式。同一事件的所有添加绑定到同一个请求头版本。添加替换当前同名定义;移除仅记录名称,不重复模式。没有添加块的消息不携带请求头引用。同名替换、重启、fork 和 surface 替换保留历史定义身份,不查询当前注册表或最新请求头。没有被任何已接纳请求暴露的中间注册表变更无需 developer 记录。
+
+`developer/message` 携带消息及其接纳 turn/step 坐标。原生读取方和 Session 接纳逻辑拒绝缺失、前向、未知、非请求头及歧义模式引用、不完整的被引用定义,并拒绝已退役的内嵌定义,同时保留无关 JSON 字段。`sourceEventSeqs` 保持为独立的派生元数据。替换保留选定的请求头,并引用被覆盖的 surface 节点;不同请求头版本的添加必须分属不同事件。fork 保留所引用的前缀,压缩不删除请求头事件。未来改变事件数量的迁移必须将 `headerSeq` 作为同一文件内的事件引用重映射。空 developer 内容保留其 surface 位置,不添加模型消息,也不能覆盖受保护的 system 头节点。tools 包按照[生产者自有来源规则](2026-09-09-producer-owned-message-sources.zh.md)拥有 `tool-registry` 来源种类。
+
+`ToolAdditionBlock.tool` 声明为可选的 `never`,并标注 `@persistenceReserved`。模式历史因此记录这个被拒绝的字段:后续允许任何值都会改变现有值类型并要求格式递增,无关的可选元数据仍属于可兼容添加。
+
+`ToolSchema.deferLoading` 请求延迟加载工具定义,与工具添加历史相互独立。工具构造、注册表投影与提示词组装保留该标记。动态添加与定义加载是不同事实;预先声明的延迟加载定义无需虚构添加记录。
+
+V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。
+
+产生方要求有一个限定例外:已定稿 Session V4 为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。
+
+## Alternatives considered
+
+**将文本提醒作为 developer 内容。** 此格式表示结构化 Session 变更;模型指令属于 system 消息。未来的 developer 内容尚未指定,不将其限制为提醒。
+
+**在每次添加中重复定义。** 完整请求头已经保留精确模式。历史引用避免第二份持久化副本,同时保留同名替换的身份。若绑定到最新请求头或注册表,则会重新解释较早的添加。
+
+**将延迟加载视为存在工具添加记录的证据。** 这会阻止预先声明的延迟工具。模式标记描述加载方式,内容块记录动态添加。
+
+## Consequences
+
+Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml
new file mode 100644
index 0000000000..08712a98da
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.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-17-frame-shell-leading-window-chrome-seat.md
+2026-09-17-frame-shell-leading-window-chrome-seat.md: cc3ef576900e38dc26c46438fd5d490ac4361be0
+2026-09-17-frame-shell-leading-window-chrome-seat.zh.md: c652f313d4f4863decc14e556a1674487d1d3fea
diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md
new file mode 100644
index 0000000000..cc3ef57690
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md
@@ -0,0 +1,38 @@
+# Agent Note: Frame-owned shell.leading window-chrome seat
+
+Status: implemented
+
+English | [中文](2026-09-17-frame-shell-leading-window-chrome-seat.zh.md)
+
+## Problem
+
+The [macOS hidden-titlebar work](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.md) hid the collapsed sidebar column entirely on darwin and moved the reopen and New Session controls into a conversation-header slot (`conversation.session.header.leading`), shown via CSS while `data-sidebar-collapsed` was published. Only the Conversation had that seat: with any other main panel selected (the plugin manager, or any future global panel), a collapsed window kept the floating traffic lights over the panel's content with no reopen control anywhere on screen. Each new panel would have had to grow its own leading seat and repeat the same clearance geometry.
+
+## Decision
+
+The window chrome belongs to the frame, not to one panel. ui-layout declares a fifth root-scoped child slot, `shell.leading` (single), and AppFrame renders its seat as a frame-level box over the columns — mounted only on the darwin desktop while `sidebarCollapsed`, the full-hide state that leaves window chrome without a home (Windows' zero-width collapse keeps its fixed caption controls, so the seat must not mount a second set there). The seat sits at the frame's top-left (left 88px clears the hiddenInset traffic lights, top 11px centers the 28px controls on the conversation title row, z-index 15 above column content and below frame overlays) and carries `-webkit-app-region: no-drag`, subtracting itself from any drag band beneath it.
+
+Under the same collapsed condition the frame publishes `--dsh-frame-leading-clearance: 160px` — the inline band the traffic lights plus the seat's two controls occupy, measured from the frame's left edge. Window fullscreen hides the traffic lights (the desktop preload mirrors the state onto `html[data-fullscreen]`): the seat moves to `left: 12px` into their vacated band and the clearance drops to `84px`. The conversation title row is the one consumer: it pads `max(0px, clearance - 20px)` (its header already pads 20px). Entry pages that start below the window strip in both sidebar states (the plugin manager's page head) instead pad their top by `--dsh-frame-top-clearance` (48px, published on the darwin frame unconditionally), which clears the strip vertically rather than indenting inline.
+
+ui-sidebar registers `HeaderLeadingControls` into `shell.leading` instead of the conversation seat, reusing the shell's inject face and locale; the component renders unconditionally because the frame owns the mount condition, and its platform check and CSS collapse-gating are deleted. This also drops ui-sidebar's dependency on ui-conversation. The original `conversation.session.header.leading` slot is gone per the pre-stable API rule (every consumer updated, no compatibility shim); the conversation header now exposes its own sessionless `conversation.header.leading` seat (root-scoped, rendered without a Session) as a public extension point, empty in the shipped composition because the window-chrome controls live in `shell.leading`.
+
+## Alternatives considered
+
+**Keeping the conversation seat and adding one per panel.** Every main panel would repeat the seat markup, the drag-region subtraction, and the traffic-light geometry, and a panel that forgot would strand a collapsed window without a reopen control. One frame-owned seat makes the guarantee structural.
+
+**Mounting the occupant always and gating visibility in CSS (the prior mechanism).** This kept dead DOM and a no-drag subtraction in the header on every platform and every collapse state, and it could not help non-conversation panels at all. A frame-level TSX mount keeps the condition, the seat geometry, and the clearance publication in one file.
+
+**Gating the seat on rightbar fullscreen as well.** Unnecessary: the fullscreen right panel (z-index 40) covers the seat (15) visually, and it already opts its whole box out of window dragging, so the seat's geometry cannot puncture it.
+
+**Publishing clearance as a fixed padding each consumer hardcodes.** A raw `160px` in each panel would silently desynchronize from the seat's real band; one custom property published under exactly the mount condition keeps consumers correct when the geometry changes.
+
+## Consequences
+
+- Every current and future main panel keeps the reopen and New Session controls while the sidebar is hidden, at zero per-panel cost; the conversation opts into `--dsh-frame-leading-clearance`, and entry pages take the unconditional `--dsh-frame-top-clearance` instead.
+- `conversation.session.header.leading` no longer exists in the client catalog; `shell.leading` carries the window chrome as a public root-scoped seat, while the conversation header's sessionless `conversation.header.leading` remains a separate public seat with no shipped occupant.
+- ui-sidebar no longer depends on ui-conversation (inject list, devDependency, and tsconfig reference removed).
+- The mount condition names darwin explicitly: Windows' `data-windows-titlebar` collapse also yields a zero-width column but keeps its own fixed caption controls, which a frame seat would duplicate. A platform that later hides its column without leaving its own chrome opts in by widening the condition.
+
+## Testing
+
+The ui-layout app-frame spec pins the seat's mount and unmount across the darwin collapse and its absence beside the 56px rail; the apply spec pins the five-slot declaration and teardown. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on dispose; the sidebar-root spec pins the two controls' actions.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md
new file mode 100644
index 0000000000..c652f313d4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md
@@ -0,0 +1,38 @@
+# Agent Note: 框架持有的 shell.leading 窗口 chrome 座
+
+Status: implemented
+
+[English](2026-09-17-frame-shell-leading-window-chrome-seat.md) | 中文
+
+## Problem
+
+[macOS 隐藏标题栏工作](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)在 darwin 上把收起的侧边栏整列隐藏,并把重新打开与 New Session 控件移入会话头部的 slot(`conversation.session.header.leading`),在 `data-sidebar-collapsed` 发布期间由 CSS 显示。但只有会话界面有这个座:选中任何其他主面板(插件管理器或未来的全局面板)时,收起的窗口只剩悬浮的红绿灯压在面板内容上,屏幕上没有任何重新打开控件。每个新面板都得自建 leading 座并重复同一套避让几何。
+
+## Decision
+
+窗口 chrome 属于框架,不属于某个面板。ui-layout 声明第五个 root 作用域子 slot `shell.leading`(single),AppFrame 把它的座渲染为覆盖各列的框架级盒子——仅在 darwin 桌面且 `sidebarCollapsed` 时挂载,即让窗口 chrome 无处安放的全隐藏状态(Windows 的零宽收起保留其固定 caption 控件,该座在那里挂载会出现第二组控件)。座位于框架左上角(left 88px 避开 hiddenInset 红绿灯,top 11px 让 28px 控件对齐会话标题行中线,z-index 15 高于列内容、低于框架浮层),并带 `-webkit-app-region: no-drag`,把自己从下方任何拖拽带中减去。
+
+在同一收起条件下框架发布 `--dsh-frame-leading-clearance: 160px`——红绿灯加座内两个控件占据的行内带宽,自框架左边缘起量。窗口全屏隐藏红绿灯(桌面 preload 把状态镜像到 `html[data-fullscreen]`):座移入其空出的带宽(`left: 12px`),避让量降到 `84px`。会话标题行是唯一消费者:它 pad `max(0px, clearance - 20px)`(其头部已 pad 20px)。两种侧栏状态下都始于窗口顶带之下的入口页(插件管理器页头)改用 `--dsh-frame-top-clearance`(48px,darwin 框架上无条件发布)做顶部内边距,纵向避开顶带而非行内缩进。
+
+ui-sidebar 把 `HeaderLeadingControls` 改注册到 `shell.leading` 而非会话座,复用 shell 的 inject face 与 locale;因挂载条件归框架所有,组件无条件渲染,其平台判断与 CSS 收起门控被删除,同时解除 ui-sidebar 对 ui-conversation 的依赖。原 `conversation.session.header.leading` slot 按 pre-stable API 规则不复存在(更新所有消费者,不留兼容垫层);会话头现在自带无会话可用的 `conversation.header.leading` 座(root 作用域、无 Session 也渲染)作为公开扩展点,因窗口 chrome 控件已住进 `shell.leading`,出厂组合中它保持空置。
+
+## Alternatives considered
+
+**保留会话座并为每个面板各加一个。** 每个主面板都要重复座标记、拖拽区减除与红绿灯几何,漏掉的面板会让收起的窗口失去重新打开控件。框架持有的单一座使该保证成为结构性的。
+
+**始终挂载占用方、由 CSS 控制可见性(先前机制)。** 它在所有平台与所有收起状态下都保留死 DOM 和头部的 no-drag 减除,且完全帮不到非会话面板。框架级 TSX 挂载把条件、座几何与避让量发布集中在一个文件。
+
+**同时以右栏全屏为座的门控条件。** 不必要:全屏右面板(z-index 40)在视觉上盖住座(15),且它已让整个盒子退出窗口拖拽,座的几何无法穿透它。
+
+**把避让量作为各消费者硬编码的固定内边距。** 各面板写死 `160px` 会与座的真实带宽悄然失同步;在恰为挂载条件下发布一个自定义属性,几何变化时消费者仍保持正确。
+
+## Consequences
+
+- 侧边栏隐藏时,现有与未来的每个主面板都免费获得重新打开与 New Session 控件;会话接入 `--dsh-frame-leading-clearance`,入口页改用无条件发布的 `--dsh-frame-top-clearance`。
+- `conversation.session.header.leading` 从客户端目录中消失;`shell.leading` 作为公开 root 作用域座承载窗口 chrome,会话头无会话可用的 `conversation.header.leading` 则是另一个公开座,出厂组合中无占用者。
+- ui-sidebar 不再依赖 ui-conversation(inject 列表、devDependency 与 tsconfig 引用均已移除)。
+- 挂载条件显式点名 darwin:Windows 的 `data-windows-titlebar` 收起同样得到零宽列,但保留自己的固定 caption 控件,框架座会与之重复。未来某平台若隐藏整列且不留自己的 chrome,通过放宽该条件接入。
+
+## Testing
+
+ui-layout 的 app-frame spec 钉住 darwin 收起下座的挂载/卸载及 56px 控制栏旁座的缺席;apply spec 钉住五 slot 声明与拆除。ui-sidebar 的 apply spec 钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 dispose 时的移除;sidebar-root spec 钉住两个控件的动作。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.i18n.yaml
new file mode 100644
index 0000000000..c3afeacd89
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.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-17-native-v4-read-validation.md
+2026-09-17-native-v4-read-validation.md: b111e9f1dd4aae6557b7893a9f334445ef0c85bc
+2026-09-17-native-v4-read-validation.zh.md: 20f1dc53b6eda42834e169c18f7b269e2933d27c
diff --git a/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.md b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.md
new file mode 100644
index 0000000000..b111e9f1dd
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.md
@@ -0,0 +1,33 @@
+# Agent Note: Mandatory native V4 relationship validation
+
+Status: implemented
+
+English | [中文](2026-09-17-native-v4-read-validation.zh.md)
+
+## Problem
+
+Tool-role results cannot be checked through the released V3 user-role representation without projecting away current fields. Replacing that validation view requires a native reader to retain the earlier lifecycle, retired-syntax, and system-message checks. The representation change and its mandatory admission therefore belong together.
+
+An event can satisfy its TypeScript declaration and message rules while contradicting earlier durable events. A tool result without an advertised call and a checkpoint naming another compaction both contain locally valid fields. Accepting either as a current Session makes later readers depend on damaged relationships, and tightening admission after release can strand already-written histories.
+
+## Decision
+
+The V4 format restorer and current JSONL scanner share one mandatory cross-event relationship pass before exposing a restored artifact or storage handle. It checks turn and step order, advertised tool calls and settlements, PTC parentage and settlement identity, retry chains and request providers, human-message title citations, command completion references, compaction ownership and current spans, and the protected system head. The installed event set controls semantic interpretation, including compaction prepasses and title references. Unknown ignorable records retain their payloads and original positions; known records remain validated even when marked ignorable. The check reads native event fields without altering artifacts or projecting them into a historical format. JSON argument comparisons inspect own properties in both Node and the worker; inherited prototype members cannot satisfy recorded fields. The [tool-role decision](2026-09-15-first-class-tool-role-messages.md) owns result representation.
+
+An unfinished tail may retain an open turn, step, tool call, or compaction. A closing event must settle the relationships it closes. An inherited unfinished compaction ends at its `session/end-seed` marker; it does not constrain the child's lifecycle. Repair message identities retain their historical numeric suffix after earlier migrations change sequence coordinates. Fork repairs retain their separately validated native identities.
+
+Installed Session adoption continues to own event envelopes, message roles and metadata, canonical request headers, and surface replacement reference coverage. Optional runtime invariants provide diagnostics; their installation is not required for durable admission. Native system-message admission also validates coordinates, identity, content blocks, and known image/tool-call fields before physical recovery can discard rows. Additional JSON fields and unknown nonempty content tags remain intact; retired tool-result wrappers are refused. Retired `request/header.header.system` and required `tool/code-dispatch*` tags remain hard refusals before recovery; obsolete ignorable PTC records remain opaque. Physical framing alone does not establish semantic validity.
+
+A retired tool-result wrapper outside its owning `tool/result` row would require changing the event, role, or source to represent it in V4. The adjacent migration lifts only the canonical owned wrapper; target restoration refuses wrappers remaining in interpreted content. Native row admission applies the same refusal before recoverable tail suppression. The check follows declared message, content, and Assistant-stream slots instead of recursively inspecting JSON, so tool arguments, replay state, schema parameters, nested extensions, and unknown ignorable payloads retain their recorded values.
+
+`EpochHeader.system` explicitly reserves the retired key as `system?: never` with `@persistenceReserved`. The catalog retains its empty value set so permitting a value is a change to a forbidden field, rather than an ordinary optional-field addition. The native reader and Session adoption continue to reject that key; system prompts remain `system/message` events.
+
+## Alternatives considered
+
+**Project V4 back into a frozen V3 validation view.** This couples current acceptance to retired representations and invites new fields to disappear in the projection. Frozen generations remain independently readable, while the current restorer interprets its own fields.
+
+**Rely on optional runtime invariants.** Detached readers and restoration paths may not install diagnostic companions. A required relationship must be checked before publication or consumption of the restored Session.
+
+## Consequences
+
+The native restorer maintains one bounded state machine per read and rejects contradictions before successor publication. Frozen V0–V3 validators and committed Session generations remain unchanged. Public catalog regressions cover invalid relationships alongside partial tails, inherited cuts, immutable unknown attribution, and complete tool exchanges; these checks enforce the declared V4 representation without changing its schemas. Breaking changes require a successor; compatible additions use new acknowledgements, and implementation fixes preserve the declared meaning.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.zh.md b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.zh.md
new file mode 100644
index 0000000000..20f1dc53b6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 原生 V4 关系的强制校验
+
+Status: implemented
+
+[English](2026-09-17-native-v4-read-validation.md) | 中文
+
+## Problem
+
+tool 角色结果不能通过已发布的 V3 user 角色表示进行校验,否则投影会丢弃当前字段。替代该校验视图后,原生读取方必须保留原有的生命周期、退役语法和 system 消息检查。因此,表示变更与必需的接纳校验需要一同完成。
+
+事件即使符合 TypeScript 声明和消息规则,也可能与较早的持久化事件矛盾。没有声明调用的工具结果、指向另一压缩事务的检查点,都可以拥有局部有效字段。将它们接纳为当前 Session 会让后续读取器依赖损坏的关系;发布后再收紧准入则可能让已写入的历史无法读取。
+
+## Decision
+
+V4 格式恢复器与当前 JSONL 扫描器共用同一强制跨事件关系校验,在公开恢复产物或存储句柄之前执行。它检查 turn 与 step 顺序、声明的工具调用及其结算、PTC 父子关系与结算身份、重试链与请求 provider、标题的人类消息引用、命令完成引用、压缩归属与当前区间,以及受保护的 system 首节点。已安装事件集合控制语义解释,包括压缩预扫描和标题引用。未知的可忽略记录保留其载荷与原始位置;已知记录即使标记为可忽略仍会接受校验。校验直接读取原生事件字段,不修改产物,也不将其投影为历史格式。Node 和 Worker 中的 JSON 参数比较都检查自有属性,继承的原型成员不能代替记录字段。[工具角色决策](2026-09-15-first-class-tool-role-messages.zh.md)负责结果表示。
+
+未完成的尾部可以保留打开的 turn、step、工具调用或压缩事务。关闭事件必须结清它关闭的关系。继承的未完成压缩事务在其 `session/end-seed` 标记处结束,不约束子 Session 的生命周期。较早迁移改变序号坐标后,修复消息身份仍保留历史数字后缀。fork 修复保留经过独立校验的原生身份。
+
+已安装的 Session 接纳器继续负责事件封装、消息角色与元数据、规范请求头,以及 surface 替换的引用覆盖。可选运行时不变式提供诊断;持久化准入不要求安装它们。原生 system 消息准入还会在物理恢复可能丢弃行之前,校验坐标、身份、内容块及已知 image/tool-call 字段。额外 JSON 字段和非空的未知内容标签保持不变,退役的 tool-result 包装会被拒绝。退役的 `request/header.header.system` 和必需的 `tool/code-dispatch*` 标签仍在恢复前强制拒绝;标记为可忽略的过时 PTC 记录保持不透明。仅通过物理分帧不能证明语义有效。
+
+退役的 tool-result 包装若不属于对应的 `tool/result` 行,就需要改变事件、角色或来源才能在 V4 中表示。相邻迁移只提升规范的自有包装;目标恢复器拒绝解释内容中剩余的包装。原生行准入在可恢复尾部被忽略之前执行同样的拒绝。检查沿声明的消息、内容和 Assistant stream 字段进行,不递归扫描 JSON,因此工具参数、replay state、schema 参数、嵌套扩展和未知可忽略载荷保留其记录值。
+
+`EpochHeader.system` 通过带有 `@persistenceReserved` 的 `system?: never` 显式保留退役键。目录保留其空值集合,使允许某个值被识别为对禁止字段的修改,而非普通可选字段添加。原生读取方和 Session 接纳继续拒绝该键;系统提示词仍由 `system/message` 事件承载。
+
+## Alternatives considered
+
+**将 V4 投影回冻结的 V3 校验视图。** 这会将当前准入与退役表示绑定,并容易让新增字段在投影中消失。冻结代际仍可独立读取,当前恢复器解释自己的字段。
+
+**依赖可选运行时不变式。** 分离读取器和恢复路径可能不安装诊断组件。必需关系必须在发布或消费恢复后的 Session 之前完成校验。
+
+## Consequences
+
+原生恢复器为每次读取维护独立状态机,在发布后继文件之前拒绝矛盾。冻结的 V0–V3 校验器及已提交的 Session 代际保持不变。公开目录回归覆盖无效关系、部分尾部、继承切分点、未知归属的不可变保存,以及完整工具交互;这些检查落实已声明的 V4 表示,不改变其 schema。破坏性变更要求后继版本;兼容新增使用新的确认记录,实现修复须保留声明的含义。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.i18n.yaml
new file mode 100644
index 0000000000..e2b272908e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.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-17-persistence-attribution-policy.md
+2026-09-17-persistence-attribution-policy.md: 19bebc8bc186f4b2b7e2e561fb6a53ceadcebabf
+2026-09-17-persistence-attribution-policy.zh.md: 3561c1d1afca5402cb94b1578603c4f1a2fc89a4
diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md
new file mode 100644
index 0000000000..19bebc8bc1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md
@@ -0,0 +1,35 @@
+# Agent Note: Recorded user-source attribution compatibility
+
+Status: implemented
+
+English | [中文](2026-09-17-persistence-attribution-policy.zh.md)
+
+## Problem
+
+A producer-owned message source appears transitively in several persisted event types. A new attribution kind changes their union fingerprints even when existing readers preserve its fields without the producer. Request-only prompts need no durable identity, but constructing them as Session messages adds unnecessary source alternatives to that union.
+
+## Decision
+
+The core-owned user-message source property binds the [recorded compatibility policy](../process/2026-09-17-persistence-schema-review.md). A producer separately qualifies its literal wire kind as attribution-only. Both saved schemas must carry compatible policy state before a qualified addition receives a same-version classification. Existing alternatives remain structurally compared; missing policy, unqualified additions, removals, and changes to existing semantic groups retain strict classification. Fixed system, model, and tool source fields do not participate.
+
+The tmux-context producer qualifies its location attribution. Its own projection uses the kind to avoid repeated injection, while readers without that producer retain the recorded content and every source JSON property. The qualification promises reader independence; it does not forbid producer-local duplicate suppression.
+
+`RequestMessage` accepts a durable `Message` or a user-only `RequestUserInput` with content and no `id` or `source`. The LLM service and provider serializers accept both; Session writes, Agent delivery, and persisted title-request inputs require durable messages. Auto Review's outer prompt and the compaction summarizer's final instruction use request-only inputs and retain their content and freezing. The caller receives no implicit durable conversion. Assistant replay metadata and tool-result correlation retain their existing requirements.
+
+The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its producer mappings and extension namespaces, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
+
+## Alternatives considered
+
+**Treat every source addition as breaking.** This would require a Session-format increment for attribution that readers already retain safely, without distinguishing it from semantic changes.
+
+**Relax every discriminated union or infer compatibility from current names.** A generic exception would admit semantic variants. Current declarations cannot establish what an older snapshot promised, and structural equality does not establish ownership of a source property.
+
+**Use durable messages for every request.** Detached prompts would retain manufactured identities and unnecessary source declarations. The explicit user-only alternative permits their removal while typed Session interfaces retain durable identity requirements.
+
+**Narrow historical context forms to current writer examples.** Writer examples do not establish every value admitted by released readers. Those read-facing variants remain represented.
+
+## Consequences
+
+A qualified addition still changes affected event fingerprints and requires an acknowledgement. Reviewers must verify the producer's attribution promise; type analysis cannot establish replay semantics or detect behavior hidden in opaque values.
+
+Codec-to-Session tests retain unknown user attribution and extra JSON fields without the producer. Provider equality tests compare request-only and durable inputs, while type assertions reject request-only inputs at durable writes. Recorded Session generations and the V3-to-V4 conversion remain independent of catalog policy and rendering.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md
new file mode 100644
index 0000000000..3561c1d1af
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md
@@ -0,0 +1,35 @@
+# Agent Note:已记录的 user-source 归属兼容性
+
+Status: implemented
+
+[English](2026-09-17-persistence-attribution-policy.md) | 中文
+
+## 问题
+
+生产者拥有的消息 source 会传递性地出现在多个持久化事件类型中。即使已有读取方无需该生产者也能保留字段,新增归属 kind 仍会改变这些联合类型的指纹。仅供请求使用的提示不需要持久身份,但将其构造成 Session 消息会为联合类型增加不必要的 source 分支。
+
+## 决策
+
+核心拥有的 user-message source 属性绑定[已记录的兼容性策略](../process/2026-09-17-persistence-schema-review.zh.md)。生产者另行将其 wire 字面量 kind 声明为仅用于归属。双方保存的 schema 必须具有兼容的策略状态,符合条件的添加才能获得同版本分类。已有分支继续接受结构比较;缺失策略、未标记的添加、删除以及已有语义分组的变更继续采用严格分类。固定的 system、model 和 tool source 字段不参与。
+
+tmux-context 生产者将位置归属标记为符合条件。其自身投影使用 kind 避免重复注入,而未安装该生产者的读取方保留已记录内容及 source 的每个 JSON 属性。该标记承诺读取方不依赖生产者,并不禁止生产者内部的去重。
+
+`RequestMessage` 接受持久化 `Message`,或仅含 content、没有 `id` 和 `source` 的 user 专用 `RequestUserInput`。LLM 服务和 provider 序列化器接受两者;Session 写入、Agent 交付及持久化标题请求输入要求持久化消息。Auto Review 的外层提示和压缩摘要器的最终指令使用仅供请求使用的输入,并保留内容与冻结行为。调用方不会获得隐式持久化转换。Assistant 回放元数据和工具结果关联保持原有要求。
+
+已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留生产者映射与扩展命名空间,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
+
+## 考虑过的替代方案
+
+**将所有 source 添加视为破坏性变更。** 这会要求为读取方已能安全保留的归属信息提升 Session 格式版本,无法区分它与语义变更。
+
+**放宽所有可辨识联合或从当前名称推断兼容性。** 通用例外会接纳语义分支。当前声明不能确定旧快照作出的承诺,结构相等也不能确定 source 属性的归属。
+
+**为每个请求使用持久化消息。** 独立提示会保留人为制造的身份和不必要的 source 声明。显式的 user 专用分支允许移除它们,同时让带类型的 Session 接口继续要求持久身份。
+
+**将历史 context 形式收窄到当前写入方示例。** 写入方示例不能确定已发布读取方接纳的全部值。这些面向读取的分支继续保留。
+
+## 影响
+
+符合条件的添加仍会改变相关事件指纹,并要求确认记录。Review 者必须验证生产者的归属承诺;类型分析不能确定回放语义,也不能发现不透明值中隐藏的行为。
+
+Codec 到 Session 的测试在未安装生产者时保留未知 user 归属和额外 JSON 字段。Provider 等价测试比较仅供请求使用的输入与持久化输入,类型断言则拒绝在持久化写入处使用仅供请求使用的输入。已记录的 Session 世代及 V3→V4 转换不依赖目录策略和渲染。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml
new file mode 100644
index 0000000000..e89e510aea
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.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-17-process-local-blank-sessions.md
+2026-09-17-process-local-blank-sessions.md: 58d222185ae67c1f70874c0d0a73910eaa1e0e5b
+2026-09-17-process-local-blank-sessions.zh.md: 0f3fdf050931d11a80e92d98577ac8b42628bb60
diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md
new file mode 100644
index 0000000000..58d222185a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md
@@ -0,0 +1,35 @@
+# Agent Note: Writer acquisition for blank Session reuse
+
+Status: implemented
+
+English | [中文](2026-09-17-process-local-blank-sessions.zh.md)
+
+## Problem
+
+Two Hosts sharing Session storage can select the same blank Session when opening a Workspace. A blank Session already owns a live Agent, accepts slash commands, and can hold its writer lock after the creation checkpoint. An unowned persisted blank also carries command settings that remain useful after a Host restart.
+
+## Decision
+
+The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection.
+
+The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema.
+
+This decision extends the blank-reuse policy in the [Client Session scope decision](2026-07-25-web-client-session-scope-and-provide-channel.md); its real-Session slash support and Provider adoption rationale remain active.
+
+## Alternatives considered
+
+**Client-only drafts.** Slash commands and Session-scoped plugins require a real Session before the first prompt. Deferring creation requires a separate command lifecycle.
+
+**Release a live blank's writer lock.** Blank means no `turn/start`, not no events. A live Agent can still append command and configuration events, so releasing its lock permits competing writers.
+
+**Hide every cold blank.** This discards reusable command settings from navigation and creates another blank after every restart, even when the old writer is free.
+
+**Persist a creator PID.** Kernel writer ownership already arbitrates acquisition. PID reuse and stale creator records add no authority.
+
+## Consequences
+
+A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow.
+
+## Verification
+
+Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md
new file mode 100644
index 0000000000..0f3fdf0509
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 复用空白 Session 时获取写锁
+
+Status: implemented
+
+[English](2026-09-17-process-local-blank-sessions.md) | 中文
+
+## Problem
+
+两个共享 Session 存储的 Host 在打开 Workspace 时可能选中同一个空白 Session。空白 Session 已有活动 Agent,可以接受 slash 命令,并且可能在创建检查点后持有写锁。无人占用的持久化空白会话也可能带有 Host 重启后仍有用的命令设置。
+
+## Decision
+
+Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。
+
+Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。
+
+本决策扩展 [Client Session scope 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中的空白会话复用策略;其真实 Session 支持 slash 以及 Provider 收养的理由仍然有效。
+
+## Alternatives considered
+
+**纯客户端草稿。** Slash 命令与 Session 作用域插件在首条提示词之前就需要真实 Session。延迟创建需要单独的命令生命周期。
+
+**释放活动空白会话的写锁。** Blank 表示没有 `turn/start`,并不表示没有事件。活动 Agent 仍可能追加命令与配置事件,因此释放其锁会允许多个写入方竞争。
+
+**隐藏所有冷空白会话。** 这会让导航丢失可复用的命令设置,并且每次重启都创建另一空白会话,即使旧写锁已空闲。
+
+**持久化创建者 PID。** 内核写锁已能仲裁获取操作。PID 复用与过时的创建者记录无法增加权威性。
+
+## Consequences
+
+Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。
+
+## Verification
+
+导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。
diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml
new file mode 100644
index 0000000000..4531b00628
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.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-17-web-feature-routes-and-route-gate.md
+2026-09-17-web-feature-routes-and-route-gate.md: 3939bdcd0c53e766827e8fcf05f64fb4d284c59b
+2026-09-17-web-feature-routes-and-route-gate.zh.md: fb329c8fc1f93b9f979612b7163e838cc521428b
diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md
new file mode 100644
index 0000000000..3939bdcd0c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md
@@ -0,0 +1,31 @@
+# Agent Note: Feature routes follow the document base, and a gate keeps them there
+
+Status: implemented
+
+English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md)
+
+## Problem
+
+[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again.
+
+## Decision
+
+Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base.
+
+Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations.
+
+`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope.
+
+## Alternatives considered
+
+**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears.
+
+**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references.
+
+## Consequences
+
+The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary.
+
+## Testing
+
+The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. The discovery spec pins which client projects contribute browser sources: a DOM project the Host aggregate also compiles contributes its plain `src/` unless it has a `src/client` half.
diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md
new file mode 100644
index 0000000000..fb329c8fc1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 功能路由遵循文档 base,并由门禁维持
+
+Status: implemented
+
+[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文
+
+## 问题
+
+[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。
+
+## 决策
+
+每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。
+
+两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。
+
+`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。
+
+## 曾考虑的替代方案
+
+**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。
+
+**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。
+
+## 后果
+
+门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。
+
+## 测试
+
+门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。发现逻辑的 spec 固定了哪些 client 项目贡献浏览器源文件:同时被 Host 聚合编译的 DOM 项目,若没有 `src/client` 半边,则其整个 `src/` 都计入。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml
new file mode 100644
index 0000000000..a005e6d029
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.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-18-agent-teams-single-bundle.md
+2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7
+2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008
diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md
new file mode 100644
index 0000000000..21878a8e48
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md
@@ -0,0 +1,33 @@
+# Agent Note: Enable Agent Teams with one bundle
+
+Status: implemented
+
+English | [中文](2026-09-18-agent-teams-single-bundle.zh.md)
+
+## Problem
+
+Separate Agent Teams and Agent Teams Web switches require users to discover that tools and their browser controls need both selections. The package split exposes composition details in the plugin page without helping users choose a different Team capability.
+
+## Decision
+
+`@deepseek-ai/dsh-experimental-agent-team-profile` carries the Team service, tools, and browser UI in one optional bundle. Its patch retains the `agent-team`, `tool-agent-team`, and `ui-agent-team` row ids, so profile patches can still configure individual rows. The UI package has an inert Host entry; its browser entry mounts only in a Web Client, and headless does not start a Web server.
+
+The separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` package is absent from the workspace and release family. The plugin page exposes one Team selection, disabled by default.
+
+This decision supersedes the separate Host/Web composition in the [package publication note](2026-08-18-experimental-agent-teams-packages.md) and the two Team selections in the [optional-bundle note](../process/2026-09-15-shipped-optional-bundles.md). Both remain active for publication, dependency isolation, promotion, and installation ownership. The [archived Web-controls note](../../archived/feature/2026-08-06-agent-teams-web.md) records the original split; its historical text remains frozen.
+
+## Alternatives considered
+
+**Keep two switches and explain their dependency.** Users still need two selections for one feature, and either selection alone leaves an incomplete browser experience.
+
+**Combine the entries in the generic profile loader or plugin manager.** Recognizing Team package names there would make shared loading or presentation code own feature-specific composition. The bundle patch already declares the required rows and dependencies.
+
+## Consequences
+
+Users enable tools and browser controls together. Separate installation-level selection of a UI-only Team bundle is unavailable; custom profiles still control individual patch rows. Headless installations include the UI dependency graph even though they do not mount its browser entry.
+
+Existing profiles that select the removed Web bundle have an upgrade compatibility gap: startup fails when that package cannot be resolved. Bundle composition provides no automatic rewrite of those saved selections. Compatibility handling remains separate from this composition decision.
+
+## Verification
+
+The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md
new file mode 100644
index 0000000000..39c6bfb7b3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 通过一个组合包启用 Agent Teams
+
+Status: implemented
+
+[English](2026-09-18-agent-teams-single-bundle.md) | 中文
+
+## 问题
+
+独立的 Agent Teams 与 Agent Teams Web 开关要求用户自行发现:工具与浏览器控件需要同时选中两项。包的拆分把组合细节暴露在插件页上,却没有帮助用户选择不同的 Team 能力。
+
+## 决策
+
+`@deepseek-ai/dsh-experimental-agent-team-profile` 在一个可选组合包中携带 Team 服务、工具与浏览器 UI。其 patch 保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 配置行 id,因此 profile patch 仍可单独配置各行。UI 包的 Host 入口不执行逻辑;其浏览器入口只在 Web 客户端中挂载,headless 不会启动 Web 服务。
+
+独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 包不在 workspace 与发布系列中。插件页提供一个 Team 选项,默认关闭。
+
+本决策取代[包发布记录](2026-08-18-experimental-agent-teams-packages.zh.md)中的独立 Host/Web 组合方式,以及[可选组合包记录](../process/2026-09-15-shipped-optional-bundles.zh.md)中的两个 Team 选项。两条记录仍负责发布、依赖隔离、promotion 与安装方所有权。[已归档的 Web 控件记录](../../archived/feature/2026-08-06-agent-teams-web.md)记载原始拆分方式,其历史文本保持冻结。
+
+## 曾考虑的替代方案
+
+**保留两个开关并解释依赖关系。** 用户仍须为一个功能选中两项,单独选中任意一项都不能提供完整的浏览器体验。
+
+**在通用 profile loader 或插件管理器中合并入口。** 在这些位置识别 Team 包名会让共用的加载或展示代码负责功能特定的组合。组合包 patch 已能声明所需配置行与依赖。
+
+## 后果
+
+用户同时启用工具与浏览器控件。安装层面不再提供仅选择 Team UI 组合包的入口;自定义 profile 仍能单独控制 patch 配置行。headless 安装包含 UI 依赖图,但不会挂载其浏览器入口。
+
+选中了已移除 Web 组合包的已有 profile 存在升级兼容性缺口:无法解析该包时,启动会失败。组合包本身不会自动改写这些已保存的选择。兼容性处理与本组合决策分开。
+
+## 验证
+
+插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml
new file mode 100644
index 0000000000..143d9ccffe
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.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-18-chat-navigation-performance.md
+2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
+2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md
new file mode 100644
index 0000000000..d990c0cb9a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md
@@ -0,0 +1,59 @@
+# Agent Note: Bound turn navigation work and batch historical jumps
+
+Status: implemented
+
+English | [中文](2026-09-18-chat-navigation-performance.zh.md)
+
+## Problem
+
+A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy.
+
+## Decision
+
+### Fixed-pitch virtual rail
+
+[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization.
+
+The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree.
+
+### Two-layer transcript navigation
+
+[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail.
+
+[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
+
+### One publication per historical jump
+
+[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
+
+### Theoretical work model
+
+Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements.
+
+| Operation | Previous work | Current work |
+|---|---|---|
+| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` |
+| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` |
+| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path |
+| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain |
+| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening |
+| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` |
+| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent |
+
+For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout.
+
+## Alternatives considered
+
+**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream.
+
+**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it.
+
+**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations.
+
+**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency.
+
+## Consequences
+
+Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event.
+
+[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md
new file mode 100644
index 0000000000..1a330d3b6b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md
@@ -0,0 +1,59 @@
+# Agent Note: 限制轮次导航工作量并聚合历史跳转
+
+Status: implemented
+
+[English](2026-09-18-chat-navigation-performance.md) | 中文
+
+## Problem
+
+长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。
+
+## Decision
+
+### 固定间距虚拟轨道
+
+[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。
+
+轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。
+
+### 两层正文导航
+
+[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。
+
+[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
+
+### 每次历史跳转统一发布
+
+[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
+
+### 理论工作量模型
+
+设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。
+
+| 操作 | 原有工作量 | 当前工作量 |
+|---|---|---|
+| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` |
+| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` |
+| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 |
+| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 |
+| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 |
+| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` |
+| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 |
+
+例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。
+
+## Alternatives considered
+
+**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。
+
+**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。
+
+**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。
+
+**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。
+
+## Consequences
+
+跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。
+
+[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml
new file mode 100644
index 0000000000..89a7fa2cc4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.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-18-localized-package-metadata.md
+2026-09-18-localized-package-metadata.md: a7e41cccbbce63547e8e5f175ec2350bcf515bc2
+2026-09-18-localized-package-metadata.zh.md: d70aed2a57db4c474245806d4bd3db0129f4beb4
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
new file mode 100644
index 0000000000..a7e41cccbb
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
@@ -0,0 +1,87 @@
+# Agent Note: Plugin-owned localized display metadata
+
+Status: implemented
+
+English | [中文](2026-09-18-localized-package-metadata.zh.md)
+
+## Problem
+
+Plugin Manager needs readable titles and descriptions for installed bundles and individual plugins, including disabled plugins. One npm package can export several plugins with different purposes; a package-level introduction cannot describe each one. Registering introductions only during activation also leaves disabled or failed plugins without display text.
+
+## Decision
+
+Each plugin supplies optional `meta.title` and `meta.description` strings in its `locale/.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses.
+
+```json
+{
+ "meta": {
+ "title": "File Search",
+ "description": "Search files in your workspace."
+ }
+}
+```
+
+The configured Cordis plugin name owns the resource address. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code:
+
+| Plugin form | Locale resource |
+|---|---|
+| npm package `my-plugins` | `my-plugins/locale/en.json`, exposed by `./locale/*.json` |
+| npm subpath `my-plugins/search` | `my-plugins/search/locale/en.json`, exposed by `./search/locale/*.json` |
+| JavaScript path `./plugins/search.js` | No sibling-resource lookup; the configured path is the final title fallback |
+
+Locale addresses are not derived from a resolved JavaScript filename or an owning package directory. File paths and file URLs, including Windows drive and UNC paths, skip resource resolution and return no metadata; a file address alone is not a metadata error. They supply neither sibling locale metadata nor a neighboring package manifest. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns directory examples, export declarations, publication entries, and author verification.
+
+Language files for one plugin share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings.
+
+The Host reads installed bundles and their declared plugin rows without importing or activating them. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy.
+
+Each field falls back independently before view-specific name formatting:
+
+| Field | Existing locale fallback chain | Same-address package fallback | Final fallback |
+|---|---|---|---|
+| Title | `meta.title` | Non-empty `name` from `/package.json` | Complete configured Cordis plugin name |
+| Description | `meta.description` | Non-empty `description` from `/package.json` | No package description |
+
+The package fallback obeys resource exports and belongs to the same plugin address; a subpath does not inherit its owning package's introduction. The Host supplies the package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations.
+
+Plugin Manager uses this metadata on installed bundle cards and details, component lists, and component configuration details, preserving complete technical-name fallbacks. Settings uses it in the plugin inventory, including preset plugins, but shortens literal package-name and module-name fallbacks by removing their npm scope and Cordis/DSH prefixes. Translated titles remain verbatim in both views; full module names, entry ids, search identities, and operation targets stay unchanged. The [Settings inventory README](../../../../packages/client/ui-settings-plugin-inventory/README.md#use-this-package) owns the prefix rules.
+
+A row configuration page uses its registered `summary` view only when the plugin has no display description. The bundle's raw `description` field remains available to Host and model consumers; it is not an additional UI fallback around resource exports. These rules preserve one displayed description while keeping configuration summaries available for plugins without one.
+
+Disabled plugins remain readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged.
+
+Built-in bundle introductions live in their exported language files. Beta markers, page grouping, configuration slot ownership, and enablement targets remain unchanged. Plugin introductions and component interaction copy retain separate owners.
+
+## Existing decisions
+
+[Public package declarations](../../implemented/architecture/2026-09-10-public-package-manifest.md) still govern manifest type and reader responsibilities; [locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md) still governs component wording and display identity; [plugin configuration pages](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md) still determine configuration slot ownership. Plugin metadata extends display data without replacing those decisions.
+
+## Alternatives considered
+
+**Keep built-in package dictionaries in Plugin Manager.** Every package addition requires changing the manager, and external plugins cannot independently supply translations.
+
+**Declare references in package.json.** This duplicates the declaration between a manifest and locale files, and one package-level declaration cannot distinguish its exported plugins.
+
+**Read locale beside the resolved JavaScript file.** Build tools can put several plugin entries in the same directory. Resource exports preserve each plugin's identity independently of its compiled layout.
+
+**Register copy when the displayed plugin activates.** Disabled packages, packages without a Client half, and packages that fail to load still need readable introductions.
+
+**Add base.json or package-specific fallback.** This introduces a second policy alongside the existing English fallback and extension-language fallback chains.
+
+**Download remote packages for pre-installation previews.** This expands lookup cost and the package contents that must be handled; this phase only reads files already present locally.
+
+## Verification
+
+- Bundles, child plugins, and read-only inventory display local metadata; disabled packages need no activation for reading.
+- Language switching updates titles and descriptions; Settings shortens only literal technical-name fallbacks, not translated titles, and keeps module identities and operation targets intact.
+- Direct metadata, unrelated locale content, absent resources, malformed JSON, missing display fields, and conflicting language identifiers have focused coverage.
+- Separate exports in one package and same-named packages under different parents retain independent metadata without executing plugin entries.
+- Windows drive paths, UNC paths, relative and absolute file paths, and file URLs return no metadata without invoking resource resolution.
+- Missing locale resources and individual fields fall back independently to address-local package fields, then the complete module specifier without a description.
+- Publication checks require readable resource exports and included language files, without duplicate ownership of built-in copy in the manager and plugins.
+- Management tools preserve existing model output, and pre-installation inspection adds no package-content lookup.
+- Focused tests and the complete `pnpm run build` succeed.
+
+## Consequences
+
+Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-plugin diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md
new file mode 100644
index 0000000000..d70aed2a57
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md
@@ -0,0 +1,87 @@
+# Agent Note: 插件自有的多语言展示元信息
+
+Status: implemented
+
+[English](2026-09-18-localized-package-metadata.md) | 中文
+
+## 问题
+
+Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题和描述,包括尚未启用的插件。同一个 npm 包可以导出多个用途不同的插件,包级介绍无法分别描述它们。依赖激活来注册介绍,也会让禁用或加载失败的插件缺少展示文案。
+
+## 决策
+
+每个插件从 `locale/en.json` 开始,在自己的 `locale/.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。
+
+```json
+{
+ "meta": {
+ "title": "File Search",
+ "description": "Search files in your workspace."
+ }
+}
+```
+
+资源地址归 Cordis 配置中的插件名所有。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码:
+
+| 插件形式 | locale 资源 |
+|---|---|
+| npm 包 `my-plugins` | 请求 `my-plugins/locale/en.json`,由 `./locale/*.json` 导出 |
+| npm 子路径 `my-plugins/search` | 请求 `my-plugins/search/locale/en.json`,由 `./search/locale/*.json` 导出 |
+| JavaScript 路径 `./plugins/search.js` | 不查找同级资源;配置中的路径名作为标题的最终回退 |
+
+locale 地址不从解析后的 JavaScript 文件名或所属包目录推导。文件路径与文件 URL(包括 Windows 盘符路径和 UNC 路径)跳过资源解析,返回无元信息;文件地址本身不构成元信息错误。它们既不提供同级 locale 元信息,也不提供旁边的包 manifest(元数据清单)。目录示例、exports 声明、发布条目与作者验证步骤由[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护。
+
+同一插件的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。
+
+Host 读取已安装 bundle 及其声明的插件行,不 import 或激活它们。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。
+
+每个字段先独立回退,再按页面规则格式化技术名称:
+
+| 字段 | 现有 locale 语言回退链 | 同地址包字段回退 | 最终回退 |
+|---|---|---|---|
+| 标题 | `meta.title` | `<插件模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 |
+| 描述 | `meta.description` | `<插件模块名>/package.json` 中非空的 `description` | 不提供包描述 |
+
+包字段回退遵守资源 exports,并且仍属于同一插件地址,子路径不继承所属包的介绍。字段有其他语言译文但没有英文值时,Host 将包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。
+
+插件管理页在已安装 bundle 的卡片和详情、组件列表、组件配置详情中使用这些元信息,保留完整技术名回退。设置中的插件清单也使用它,包括预设内插件,但会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀。两个页面都原样展示翻译标题;完整模块名、条目 id、搜索身份与操作目标保持不变。具体前缀规则由[设置插件清单 README](../../../../packages/client/ui-settings-plugin-inventory/README.zh.md#use-this-package)维护。
+
+行配置页仅在插件没有展示描述时使用注册组件的 `summary` 视图。组合包原始的 `description` 字段仍供 Host 与模型消费方使用,不作为绕过资源 exports 的额外 UI 回退。这些规则只展示一份描述,同时保留无描述插件的配置摘要。
+
+禁用插件不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。
+
+内置 bundle 的介绍放在其导出的语言文件中。beta 标记、页面分组、配置槽归属与启停目标保持原样。插件介绍和组件自身的交互文案各自保持所有权。
+
+## 现有决策
+
+[公共包声明](../../implemented/architecture/2026-09-10-public-package-manifest.zh.md) 继续约束 manifest 类型和读取者的职责;[locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md) 继续约束组件文案和展示身份;[插件配置页](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) 继续决定配置槽归属。插件元信息只扩展展示数据,不取代这些决策。
+
+## 考虑过的替代方案
+
+**在 Plugin Manager 中继续维护内置包词典。** 每增加一个包都需要修改管理器,而且外部插件无法独立提供翻译。
+
+**在 package.json 中声明引用。** 将声明重复放在 manifest 与 locale 文件中,而且一份包级声明无法区分包内导出的插件。
+
+**从解析后的 JavaScript 文件旁读取 locale。** 构建工具可能把多个插件入口放在同一目录。资源 exports 让各插件的身份独立于编译产物布局。
+
+**由被展示插件激活时注册文案。** 未启用包、无 Client 半边的包和加载失败的包仍然需要可读的介绍。
+
+**增加 base.json 或包级 fallback。** 会与现有 locale 的英文兜底和扩展语言回退链产生第二套规则。
+
+**为安装前预览下载远程包。** 扩大查询成本及需要处理的包内容;本期只处理已经存在于本地的文件。
+
+## 验证
+
+- bundle、子插件与只读 inventory 展示本地元信息;禁用包不需要激活即可读取。
+- 切换语言更新标题和描述;设置页仅缩短字面技术名回退,不缩短翻译标题,也不改变模块身份和操作目标。
+- 直接元信息、无关 locale 内容、缺失资源、损坏 JSON、缺失展示字段及语言标识冲突均有针对性覆盖。
+- 同包的不同 exports,以及不同父目录中的同名包,各自保持独立元信息,读取不执行插件入口。
+- Windows 盘符路径、UNC 路径、相对与绝对文件路径及文件 URL 均不调用资源解析,直接返回无元信息。
+- locale 资源或个别字段缺失时,各字段独立回退到该插件地址的包字段,再回退到完整模块名且不显示描述。
+- 发布检查要求资源 exports 可解析且包含语言文件,内置文案不在管理器和插件中双份维护。
+- 管理工具保留现有模型输出,安装前查询不增加包内容探查。
+- 针对性测试以及完整 `pnpm run build` 通过。
+
+## 影响
+
+遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单插件诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.i18n.yaml
new file mode 100644
index 0000000000..7ca268a8fc
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.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-18-plugin-install-registries.md
+2026-09-18-plugin-install-registries.md: bee870a87b1f5427af8f05a32396bd52bd1524b7
+2026-09-18-plugin-install-registries.zh.md: 65fe35c78910d1cdf1ce45aea7d9fa7ae8617006
diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md
new file mode 100644
index 0000000000..bee870a87b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md
@@ -0,0 +1,45 @@
+# Agent Note: Plugin install registries
+
+Status: implemented
+
+English | [中文](2026-09-18-plugin-install-registries.zh.md)
+
+## Problem
+
+The install dialog handed the spec to `pnpm add` with no registry of its own, so pnpm resolved packages from whatever its configuration named, which for a person who never wrote an `.npmrc` is `registry.npmjs.org`. On a network that cannot reach it, every install failed with a network error after pnpm's own retries, which take over a minute for a registry that does not answer; the check before the install failed the same way, and nothing offered a mirror. A mirror is the ordinary answer in mainland China, but pnpm's `--registry` is a CLI flag the dialog could not pass, and an `.npmrc` is not something the dialog's audience edits.
+
+## Decision
+
+**The manager asks a plan of registries, not one.** `PluginManager.Config` gains `registry`, the registry asked first (absent: `null`, the one pnpm's own configuration names), and `fallbackRegistries`, the registries asked after it, defaulting to npmmirror. `registryPlan` resolves one operation's plan: the requested registry, else the configured first, then the rest of the configured set. A requested registry outside that set is asked alone: a private registry never falls through to a public one, where a name that exists on both would install someone else's package. Registries are compared in pnpm's form, lower-case host with a trailing slash.
+
+**pnpm's own registry is public only once read as such.** `null` names whatever the machine's `.npmrc` chain says, which may be a company registry. Before each plan the manager reads it through `pnpm config get registry` in the profile directory, and `null` joins the configured set only while that is npm's own registry or one of the configured fallbacks; otherwise it is asked alone, whether requested as `null` or by its URL, and a public registry asked instead never falls back into it. A registry pnpm already names is asked once rather than twice. Reading fails closed: an unreadable answer leaves `null` alone. `registries()` returns the reading as `resolved`, so the dialog labels the option by the host pnpm names when that is not npm's own.
+
+**Only a failure another registry can change sends the operation on.** `attributeFailure` lays `network` and `timeout`, and `not-found` and `no-matching-version`, at the registry, because a mirror lags the registry it copies and a package published minutes ago is not there yet; every other kind stops at once. A git spec and a tarball URL carry the `host` pnpm fetches them from, which no registry stands in for: a failure whose error line names that host is laid at the spec's host and stops, while a failure naming the registry, from which the spec's dependencies come, moves on; a warning line that merely links to the host does not count. The attribution reaches the caller as `ChangeResult.failedAt`, so the dialog words the failure and offers another registry from the Host's reading rather than its own guess. The classifier gains npm's `FETCH_ERROR` as a network failure; pnpm's `ERR_PNPM_NO_OFFLINE_META` is an offline cache miss no registry changes and stays unclassified.
+
+**The check decides which registry is alive; the install starts there.** `inspect` runs `pnpm view` with `--registry` and `--config.fetch-retries=0`, so a registry that does not answer is reported within `inspectTimeoutMs` — pnpm's own schedule waits ten seconds and then a minute between retries — and the next one is asked; a lookup killed at that bound is refused as a `network` problem like a refused connection. pnpm prints a `--json` refusal as `{ error: { code, message } }` on stdout with nothing on stderr, so the classifier reads both. The accepted answer names the `registry` that answered; the client passes it to `installBundle`, which starts there rather than at a registry the check already found dead. The install keeps pnpm's retry settings: it downloads tarballs, which a shorter timeout would cut on a slow but working network, and it moves on to the next registry only for a failure mid-run. `pnpm-workspace.yaml` is not written for this: `fetchRetries` and `fetchTimeout` there would bound the install's downloads as well as the check, and `--config.fetch-timeout` passed on the command line crashes pnpm 11.8, which reads it as a string.
+
+**Every attempt is visible.** `installBundle` announces `installing` once per registry with the attempt's registry, position, and the plan's length, restores the profile files between attempts and stops before starting a run on a signal the person already aborted, streams each run under its own job id with `--registry` on the command line, and reports the `registries` asked beside the last run's result. A refused check names the `registries` it asked too. The dialog offers the configured set with pnpm's own registry, titled with the host it names, and a typed address, starts from the registry the Host asks first until a choice is remembered in the browser's `localStorage`, says which registry is asked now while the Host moves on, badges each run's registry, names every registry asked when none could be reached, and names the spec's own host when the Host laid the failure there. It does not describe the plan: the order asked is the Host's, shown as it happens. A typed address is checked against the same `REGISTRY_URL` the Host validates with, published through the package's `./registry` entry for the browser.
+
+## Alternatives considered
+
+**Write the chosen registry into the profile's `.npmrc` or `pnpm-workspace.yaml`.** Rejected: the choice belongs to one install, not to every later pnpm command in the profile, and a file pnpm reads on every run would have to be rewritten and restored around each attempt.
+
+**Keep the last choice in Host settings.** Rejected for now: a settings namespace, its controller write path, and its catalog entry buy a choice shared across browsers and with the CLI, which `dsh plugin add --registry` already takes on its command line; the dialog's choice is passed to each call explicitly, so the Host holds no state for it and the browser remembers it.
+
+**Retry through every registry on any failure.** Rejected: a blocked build script, a full disk, or a package that declares no bundle fails the same way everywhere, and a git host that cannot be reached is not reached through any registry; retrying those costs the person a wait and hides the cause.
+
+**Fall back to the public registries after a private one.** Rejected: dependency confusion. A name that a private registry serves and a public one also holds would install the public package when the private registry is down. This holds for a private registry the machine's `.npmrc` names as much as for one typed into the dialog, which is why pnpm's own registry is read before it may join the plan.
+
+**Treat pnpm's own registry as public without reading it.** Rejected: the shipped configuration would then send every `.npmrc`-configured company registry's failures on to npmmirror. Reading it costs one `pnpm config get registry` per plan, about 170 ms.
+
+**Let the dialog describe the plan.** Rejected: a copy of the membership rule drifted at once — a typed mirror URL without its trailing slash read as a lone registry in the dialog while the Host normalized it into the set — and a sentence about fallbacks under the options was noise to the person choosing one. The dialog shows the order only as the Host asks it.
+
+**Bound the install's fetches like the check's.** Rejected: the check makes one small request and its liveness answer is what the plan needs; the install's tarballs on a slow but working connection need pnpm's defaults, and the registry the check found alive is the one the install starts at.
+
+## Consequences
+
+`Config` gains `registry` and `fallbackRegistries`; `inspect` takes `InspectOptions` before its signal and answers the registry that answered, the spec's own host, and the registries a refusal asked; `InstallBundleOptions`, `ChangeResult` (with `registries` and `failedAt`), `PluginInstallProgress`, and the `plugin_manager` tool carry the registry; `registries()` joins the Remote and answers `resolved`. `viewProfilePackage` takes a `registry` and never lets pnpm retry; `readProfileRegistry` asks pnpm what its configuration names. The package publishes `./registry` — `registryPlan`, `normalizeRegistry`, `REGISTRY_URL` — as a browser-safe entry the client bundle inlines; the dialog uses `REGISTRY_URL`. Every plan spends one pnpm process on the reading; a machine whose pnpm cannot be read gets no fallback.
+
+## Testing
+
+`packages/boot/plugin-manager/tests/registry.spec.ts` pins the plan, including a private and an unknown pnpm registry, and the attribution; `install-spec.spec.ts` pins the hosts the spec forms carry and the classifier's patterns; `manager.spec.ts` asks the registries in turn from the check and the install, reads a refusal printed on stdout, restores the files between attempts, stops at a git host and at a registry outside the configured set, reports a stop between attempts as cancelled, and refuses a registry that is not an http(s) URL at load; `operations.spec.ts` pins the lookup's arguments and the reading of pnpm's registry. `packages/client/ui-plugin-manager/tests` cover the store's choice, its memory across controllers, the attempts the Host announces, the return to the spec with the options open, and the page's picker, attempt copy, run badges, and failure sentences.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md
new file mode 100644
index 0000000000..65fe35c789
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md
@@ -0,0 +1,45 @@
+# Agent Note: 插件安装的注册表
+
+Status: implemented
+
+[English](2026-09-18-plugin-install-registries.md) | 中文
+
+## Problem
+
+安装对话框把 spec 交给 `pnpm add` 时不指定注册表,pnpm 就从它自身配置指定的地方解析包;对于从未写过 `.npmrc` 的人,那就是 `registry.npmjs.org`。在连不上它的网络里,每次安装都在 pnpm 自身重试之后以网络错误告终——对不应答的注册表,这要一分多钟;安装前的检查也以同样方式失败,而没有任何地方提供镜像。镜像是中国大陆的常规答案,但 pnpm 的 `--registry` 是对话框传不了的命令行参数,而 `.npmrc` 不是对话框的受众会去编辑的东西。
+
+## Decision
+
+**管理器询问的是一个注册表计划,不是一个注册表。** `PluginManager.Config` 新增 `registry`——首先询问的注册表(缺省为 `null`,即 pnpm 自身配置指定的那个)和 `fallbackRegistries`——随后依次询问的注册表,默认为 npmmirror。`registryPlan` 解出一次操作的计划:请求的注册表,否则配置的第一个,然后是配置集合中的其余。请求的注册表不在这个集合里时只问它自己:私有源永远不会落到公共源,否则两边都有的名字会装上别人的包。注册表按 pnpm 的形式比较:小写主机名加尾部斜杠。
+
+**pnpm 自身的注册表只有读出来是公共的才算公共。** `null` 指向本机 `.npmrc` 链说的任何地方,可能是公司注册表。每次做计划前管理器在 profile 目录里用 `pnpm config get registry` 读出它,只有它是 npm 官方源或某个配置的备选源时 `null` 才进入配置集合;否则不论以 `null` 还是以它的 URL 请求都只问它自己,改问公共源时也绝不回落到它。pnpm 已经指向的注册表只问一次而不是两次。读取失败时保守处理:读不出来就让 `null` 只问自己。`registries()` 把读出的结果作为 `resolved` 返回,所以 pnpm 指向的不是 npm 官方源时,对话框按其主机名标注该选项。
+
+**只有换一个注册表能改变的失败才让操作继续。** `attributeFailure` 把 `network`、`timeout` 归于注册表,把 `not-found`、`no-matching-version` 也归于注册表,因为镜像落后于它复制的注册表,几分钟前刚发布的包还没到那里;其他种类立即停止。git spec 和 tarball URL 携带 pnpm 拉取它们的 `host`,没有注册表能替代它:错误行点名那台主机的失败归于 spec 自身主机并停止,而点名注册表(spec 的依赖来自那里)的失败则继续;仅仅链接到该主机的警告行不算。归因以 `ChangeResult.failedAt` 交给调用方,所以对话框按 Host 的判断而不是自己的猜测来措辞并决定是否提供换源。分类器新增 npm 的 `FETCH_ERROR` 作为网络失败;pnpm 的 `ERR_PNPM_NO_OFFLINE_META` 是离线缓存缺失,换注册表改变不了,保持未分类。
+
+**检查决定哪个注册表活着;安装从那里开始。** `inspect` 以 `--registry` 和 `--config.fetch-retries=0` 运行 `pnpm view`,所以不应答的注册表会在 `inspectTimeoutMs` 内报告——pnpm 自身的节奏是重试之间先等十秒再等一分钟——然后询问下一个;在这个上限被杀掉的查询与连接被拒一样作为 `network` 问题拒绝。pnpm 把 `--json` 的拒绝以 `{ error: { code, message } }` 打印在 stdout 而 stderr 为空,所以分类器两者都读。通过的答复带上作答的 `registry`;客户端把它传给 `installBundle`,安装从那里开始,而不是从检查已经发现死掉的注册表开始。安装保留 pnpm 的重试设置:它要下载 tarball,更短的超时会在慢但可用的网络上掐断下载,且它只在运行中途失败时才转问下一个注册表。为此不写 `pnpm-workspace.yaml`:那里的 `fetchRetries` 与 `fetchTimeout` 会同时约束安装的下载和检查,而命令行传入的 `--config.fetch-timeout` 会让 pnpm 11.8 崩溃,因为它被当作字符串读取。
+
+**每次尝试都可见。** `installBundle` 每问一个注册表通告一次 `installing`,带上这次的注册表、序号和计划长度,两次尝试之间恢复 profile 文件,并在用户已经取消时不再以失效的信号启动下一次运行,每次运行以自己的 job id 流式输出且命令行带 `--registry`,并在最后一次运行的结果旁报告问过的 `registries`。被拒绝的检查同样列出它问过的 `registries`。对话框提供配置集合、pnpm 自身的注册表(标题带上它指向的主机名)和手动输入的地址,在浏览器 `localStorage` 记住选择之前从 Host 首先询问的注册表开始,在 Host 转问时说明现在问的是哪个注册表,给每次运行标上注册表,所有注册表都连不上时逐一列出,而 Host 把失败归于 spec 自身主机时则点名那台主机。它不描述计划:询问顺序是 Host 的,发生时才显示。手动输入的地址用 Host 校验所用的同一个 `REGISTRY_URL` 检查,该常量经包的 `./registry` 入口发布给浏览器。
+
+## Alternatives considered
+
+**把所选注册表写进 profile 的 `.npmrc` 或 `pnpm-workspace.yaml`。** 否决:这个选择属于一次安装,不属于 profile 里之后的每条 pnpm 命令,而 pnpm 每次运行都读的文件得在每次尝试前后改写再恢复。
+
+**把上次选择存进 Host 设置。** 暂时否决:一个设置命名空间、它的控制器写入路径和目录条目换来的是跨浏览器并与 CLI 共享的选择,而 `dsh plugin add --registry` 已经在命令行接受它;对话框的选择显式传给每次调用,Host 不为它保存状态,由浏览器记住。
+
+**任何失败都换遍所有注册表。** 否决:被拦下的构建脚本、写满的磁盘、不声明组合包的包在哪里都同样失败,连不上的 git 主机经由任何注册表也连不上;重试这些只让人白等,还掩盖原因。
+
+**私有源之后落到公共源。** 否决:依赖混淆。私有源提供而公共源也有的名字,会在私有源宕机时装上公共源的包。这对本机 `.npmrc` 指定的私有源和对话框里手填的私有源同样成立,所以 pnpm 自身的注册表要先读出来才可能进入计划。
+
+**不读取就把 pnpm 自身的注册表当作公共源。** 否决:那样出厂配置会把每个 `.npmrc` 配置了公司注册表的用户的失败转到 npmmirror。读取的代价是每次计划一个 `pnpm config get registry` 进程,约 170 ms。
+
+**让对话框描述计划。** 否决:成员规则的副本立刻就漂移了——手填没有尾斜杠的镜像 URL 在对话框里读作单独的注册表,而 Host 归一化后把它并入集合——而且选项下方一句关于备选的说明对正在选源的人只是噪音。对话框只在 Host 询问时按实际顺序显示。
+
+**像检查一样限制安装的请求。** 否决:检查只发一个小请求,它的存活答复正是计划所需;安装在慢但可用的连接上下载 tarball 需要 pnpm 的默认值,而安装从检查发现活着的注册表开始。
+
+## Consequences
+
+`Config` 新增 `registry` 与 `fallbackRegistries`;`inspect` 在 signal 之前接受 `InspectOptions`,并答复作答的注册表、spec 自身的主机和拒绝时问过的注册表;`InstallBundleOptions`、`ChangeResult`(含 `registries` 与 `failedAt`)、`PluginInstallProgress` 和 `plugin_manager` 工具携带注册表;`registries()` 加入 Remote 并答复 `resolved`。`viewProfilePackage` 接受 `registry` 且从不让 pnpm 重试;`readProfileRegistry` 向 pnpm 询问其配置指向哪里。包把 `./registry`——`registryPlan`、`normalizeRegistry`、`REGISTRY_URL`——作为浏览器安全的入口发布,客户端 bundle 内联它;对话框使用 `REGISTRY_URL`。每次计划花费一个 pnpm 进程做读取;pnpm 读不出来的机器没有备选。
+
+## Testing
+
+`packages/boot/plugin-manager/tests/registry.spec.ts` 钉住计划(含私有与未知的 pnpm 注册表)与归因;`install-spec.spec.ts` 钉住各 spec 形式携带的主机和分类器的模式;`manager.spec.ts` 从检查和安装依次询问注册表、读取打印在 stdout 的拒绝、在尝试之间恢复文件、在 git 主机和配置集合之外的注册表处停下、把两次尝试之间的取消报告为已取消,并在加载时拒绝不是 http(s) URL 的注册表;`operations.spec.ts` 钉住查询的参数和 pnpm 注册表的读取。`packages/client/ui-plugin-manager/tests` 覆盖 store 的选择、跨控制器的记忆、Host 通告的尝试、带着展开选项回到 spec,以及页面的选择器、尝试文案、运行标记和失败句子。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml
new file mode 100644
index 0000000000..d8c7d77519
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.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-18-profile-plugin-host-runtime-instances.md
+2026-09-18-profile-plugin-host-runtime-instances.md: 76135318b3a935e4588c360df3210618a6c69216
+2026-09-18-profile-plugin-host-runtime-instances.zh.md: b2229574876b0ad326348ababd6b6fca6f3a1ac2
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
new file mode 100644
index 0000000000..76135318b3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
@@ -0,0 +1,37 @@
+# Agent Note: Profile plugins share the installation's host runtime instances
+
+Status: implemented
+
+English | [中文](2026-09-18-profile-plugin-host-runtime-instances.zh.md)
+
+## Problem
+
+A plugin the profile installs out-of-tree resolves its bare specifiers along the ancestor `node_modules` chain, where the profile's own layer sits above the runtime resolution's interception layer ([lookup order](2026-09-19-profile-resolution-lookup-order.md)). It therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer.
+
+Issue #4573 is that failure in production. While this package reached both runtimes through `dependencies`, a profile that mounted the Playwright MCP browser provider installed a second `dsh-scope`: the first Agent's MCP tools registered globally, the second Agent's tool synchronization collided, and `failOnStartupError: true` turned that collision into a rejected Agent creation. Matching the profile's package versions to the Host's did not change the outcome, because a duplicate exists at every version.
+
+## Decision
+
+An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile, and the import reaches the installation's single instance through the runtime resolution's interception layer at `$DSH_HOME/profiles/node_modules` ([lookup order](2026-09-19-profile-resolution-lookup-order.md)).
+
+`@deepseek-ai/dsh-scope` carries that identity through `createScope` as well as through the readers `scopeOf`, `carrierKeyOf`, and `scopeTarget`: the writer mints the tag its readers compare. `@deepseek-ai/dsh-mcp-client` joins it because its live `serverName` reservations are module-local, so a second copy keeps a second reservation table for the same namespace. `@deepseek-ai/dsh-experimental-browser-use-runtime` therefore declares both as peers with matching dev entries and keeps `@deepseek-ai/schemastery` as its only runtime dependency, which carries no instance-local state.
+
+`tests/shared-host-runtimes.spec.ts` pins those sections for that package. `tests/host-runtime-duplication.spec.ts` reproduces what the rule prevents: one second copy of both packages puts each Agent's MCP tools in the global layer and rejects the second Agent's creation, while the shipped single-instance layout keeps the global layer empty and gives both Agents their own client.
+
+The [published dependency faces](../process/2026-08-26-published-dependency-faces.md) note owns `verify-package-dependencies`: its `peerRequiredHostExports` table classifies the `dsh-scope` readers, and its package selection covers Client-faced packages and an explicit Host list. That gate selects no package under `packages/experimental/`, and it does not classify `createScope`; this note owns the rule for the out-of-tree plugin packages the gate does not reach.
+
+## Alternatives considered
+
+**Mount the MCP client on `agent.ctx` instead of a scope this package mints.** It loses because the profile copy's `scopeOf` also cannot read the tag the installation wrote for `agent.ctx`, so the `serverName` reservation degrades to the process-global owner and the second Agent fails at that check — the error form seen on `0.1.6-alpha.1`. Removing the duplicate is the repair for both forms.
+
+**Make `dsh-scope`'s tag a `Symbol.for`, so two copies agree.** That fixes the tag lookup alone. Scope parents and event-carrier keys stay per copy, so the chain walk and carrier admission of one copy remain blind to links the other recorded — a smaller visible failure with a more confusing cause. It also keeps the duplicate that the profile never needed.
+
+**Match the profile's package versions to the Host's.** Equal versions still load two module instances from two directories, so the defect survives it.
+
+## Consequences
+
+- A released fix reaches a profile when the plugin dependency is upgraded and reinstalled. An already-installed profile keeps its copies until then, and no Host-side change can repair it.
+- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the runtime resolution supplies them; a plugin outside the installation's closure would fail to load rather than start with a second copy.
+- `docs/module-graph.md` renders these edges in its peer section, so the graph states the ownership boundary that the sections encode.
+- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so their own use does not reproduce this decision's failure. A profile that installs one of them beside this package hoists that dependency into the profile's own `node_modules`, above the interception layer, so this package's peer resolves to that copy: the two `dsh-mcp-client` instances keep separate `serverName` reservations, and a name reserved on one is not detected on the other. The scope split does not return, because none of them declares `@deepseek-ai/dsh-scope`. Issue #4628 tracks moving those three declarations to peers with their own verification.
+- `verify-package-dependencies` still selects no `packages/experimental/` package, so the sections of this package are pinned only by `tests/shared-host-runtimes.spec.ts`. Extending that gate's selection is the open coverage gap this note records.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md
new file mode 100644
index 0000000000..b222957487
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Profile 插件共享安装自带的宿主运行时实例
+
+Status: implemented
+
+[English](2026-09-18-profile-plugin-host-runtime-instances.md) | 中文
+
+## Problem
+
+profile 从 npm 装进来的 out-of-tree 插件,沿祖先 `node_modules` 链解析裸模块名,而 profile 自己那一层位于运行时解析(runtime resolution)的拦截层之上([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。于是它会为经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。
+
+Issue #4573 就是这个问题在生产里的形态。当本包经 `dependencies` 到达这两个运行时时,挂载 Playwright MCP 浏览器提供方的 profile 多装了一份 `dsh-scope`:第一个 Agent 的 MCP 工具注册到全局层,第二个 Agent 的工具同步撞名,`failOnStartupError: true` 把这次撞名放大成 Agent 创建失败。把 profile 的包版本对齐到 Host 并不能改变结果,因为无论哪个版本,副本都是第二份模块实例。
+
+## Decision
+
+Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile;导入走到 `$DSH_HOME/profiles/node_modules` 时,由运行时解析(runtime resolution)的拦截层提供安装里的那一份实例([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。
+
+`@deepseek-ai/dsh-scope` 的同一性既在读取者 `scopeOf`、`carrierKeyOf`、`scopeTarget` 上,也在写入者 `createScope` 上:标记由写入者铸造,再由读取者比较。`@deepseek-ai/dsh-mcp-client` 同属这一类,因为它在线的 `serverName` 保留表是模块级的,第二份副本会为同一命名空间保留第二张表。因此 `@deepseek-ai/dsh-experimental-browser-use-runtime` 把两者声明为 peer(并补匹配的 dev 条目),只把不携带实例内状态的 `@deepseek-ai/schemastery` 留在运行时依赖里。
+
+`tests/shared-host-runtimes.spec.ts` 钉住这个包的分区。`tests/host-runtime-duplication.spec.ts` 复现这条规则拦下的问题:两份包各多一份副本时,每个 Agent 的 MCP 工具都落进全局层,第二个 Agent 的创建被拒;而实际发布的单实例布局下全局层为空,两个 Agent 各拿到自己的客户端。
+
+[Published dependency faces](../process/2026-08-26-published-dependency-faces.zh.md) 拥有 `verify-package-dependencies`:它的 `peerRequiredHostExports` 表分类了 `dsh-scope` 的读取者,它的包选择覆盖 Client 面的包与一份显式 Host 名单。该 gate 不选择 `packages/experimental/` 下的任何包,也没有分类 `createScope`;gate 覆盖不到的 out-of-tree 插件包,由本记录拥有这条规则。
+
+## Alternatives considered
+
+**把 MCP 客户端挂到 `agent.ctx`,而不是本包自造的作用域。** 它不成立,是因为 profile 那份副本的 `scopeOf` 同样读不到安装为 `agent.ctx` 写下的标记,`serverName` 保留会退化成进程全局所有者,第二个 Agent 在那一步就失败——即 `0.1.6-alpha.1` 上看到的错误形态。消除重复副本才是两种形态共同的修复。
+
+**把 `dsh-scope` 的标记改成 `Symbol.for`,让两份副本互相认得。** 这只修好标记查找。作用域父链与事件载体键仍各留一份,一份副本的链式遍历与载体准入看不见另一份记录的关系——可见错误更小,成因更难定位,而且 profile 本不需要的那份重复依然存在。
+
+**把 profile 的包版本对齐到 Host。** 版本相同,两个目录依旧加载出两份模块实例,问题照旧。
+
+## Consequences
+
+- 修复要生效,需要升级并重装 profile 里的插件依赖。在此之前,已装好的 profile 一直带着那份副本,Host 侧无法补救。
+- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,运行时解析能提供它们;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。
+- `docs/module-graph.md` 把这些边画进 peer 区,图与分区表达的是同一条所有权边界。
+- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,因此自身不会触发本决策要防的故障。若一个 profile 把其中之一与本包一起安装,hoisted 会把那份依赖平铺到 profile 自己的 `node_modules`,位置高于拦截层,于是本包的 peer 解析到那份副本:两份 `dsh-mcp-client` 各存一张 `serverName` 保留表,一份上保留的名字在另一份上不再被检测。作用域标记的分裂不会回来,因为它们都没有把 `@deepseek-ai/dsh-scope` 写成依赖。Issue #4628 记录把这三处声明改为 peer 并各自验证的后续工作。
+- `verify-package-dependencies` 仍不选择 `packages/experimental/` 下的包,所以本包的分区只由 `tests/shared-host-runtimes.spec.ts` 钉住。扩大该 gate 的选择范围,就是本记录留下的覆盖缺口。
diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.i18n.yaml
new file mode 100644
index 0000000000..a66f2afe77
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.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-19-lossless-projection-checkpoint-json.md
+2026-09-19-lossless-projection-checkpoint-json.md: 9c7d15dbd2d570eaa544f168d2c2ef882558d5ca
+2026-09-19-lossless-projection-checkpoint-json.zh.md: 8c2b720358351af33a6d07888002c65d0cc30363
diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md
new file mode 100644
index 0000000000..9c7d15dbd2
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md
@@ -0,0 +1,24 @@
+# Agent Note: Lossless projection checkpoint JSON
+
+Status: implemented
+
+English | [中文](2026-09-19-lossless-projection-checkpoint-json.zh.md)
+
+## Problem
+
+Projection checkpoints can contain opaque extension data and message metadata. A JSON key named `__proto__` is ordinary recorded data. The Zod JSON parser drops that own key while rebuilding objects, so reopening a valid checkpoint can yield different projection state from replaying the Session log.
+
+## Decision
+
+The checkpoint value schema uses the existing `isJsonValue` predicate from `dsh-util-values`. It enforces the same lossless JSON rules as the checkpoint writer's `snapshotJsonValue` without rebuilding valid objects. Validation still rejects non-JSON and lossy values. Storage-domain table values are immutable borrowed records; the validator does not supply a defensive-copy guarantee.
+
+## Alternatives considered
+
+- **Keep `z.json()`.** Its object reconstruction removes valid own keys, so a successful validation can still change the checkpoint value.
+- **Validate and clone with `snapshotJsonValue`.** This preserves keys but copies already immutable stored values. The read-only predicate matches the storage-domain borrowed-value contract.
+
+## Consequences
+
+Domain reads preserve every valid own key in checkpoint values, including nested `__proto__` and `constructor` properties. A projection's `stateSchema` still owns its hydration value; opaque fields must use validation that preserves their keys. The borrowed-value validator does not create an independent copy or project to JSON Schema.
+
+The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a synthetic fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. [Predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md) continue to own cache-version and identity compatibility.
diff --git a/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md
new file mode 100644
index 0000000000..8c2b720358
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.zh.md
@@ -0,0 +1,24 @@
+# Agent Note: 无损投影检查点 JSON
+
+Status: implemented
+
+[English](2026-09-19-lossless-projection-checkpoint-json.md) | 中文
+
+## 问题
+
+投影检查点可以包含不透明扩展数据和消息元数据。名为 `__proto__` 的 JSON 键是普通记录数据。Zod JSON 解析器在重建对象时丢弃该自有键,因此重新打开有效检查点可能得到与回放 Session 日志不同的投影状态。
+
+## 决策
+
+检查点值 schema 使用 `dsh-util-values` 中既有的 `isJsonValue` 谓词。它执行与检查点写入器 `snapshotJsonValue` 相同的无损 JSON 规则,不重建有效对象。校验仍拒绝非 JSON 和有损值。Storage-domain 表值是不可变的借用记录;校验器不提供防御性复制保证。
+
+## 备选方案
+
+- **保留 `z.json()`。** 其对象重建会移除有效自有键,因此校验成功仍可能改变检查点值。
+- **用 `snapshotJsonValue` 校验并复制。** 这能保留键,但会复制已经不可变的存储值。只读谓词符合 storage-domain 的借用值约定。
+
+## 影响
+
+Domain 读取保留检查点值中的每个有效自有键,包括嵌套的 `__proto__` 和 `constructor` 属性。Projection 的 `stateSchema` 仍拥有其 hydration 值;不透明字段必须使用保留其键的校验器。借用值校验器不生成独立副本,也不投影为 JSON Schema。
+
+存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的合成 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。[前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md) 继续负责缓存版本与身份兼容性。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml
similarity index 55%
rename from .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml
index 003ab9ba65..f625fbdf15 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md
-2026-09-14-transcript-width-handle-layering.md: 01d70e646e874d8266633597f1b510ad73fc2287
-2026-09-14-transcript-width-handle-layering.zh.md: c6e03ec5775256f5aac87d2f7c1ed66a12cf0c2a
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md
+2026-09-19-profile-resolution-lookup-order.md: da9c501cff6c902863def6d39e4052097c75bcb3
+2026-09-19-profile-resolution-lookup-order.zh.md: 72a9e876a49770ed2bdc544e2ee9104619eedc80
diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md
new file mode 100644
index 0000000000..da9c501cff
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md
@@ -0,0 +1,141 @@
+# Agent Note: Resolve profile modules along the ancestor chain with an interception layer
+
+Status: implemented
+
+English | [中文](2026-09-19-profile-resolution-lookup-order.zh.md)
+
+## Problem
+
+A profile loads plugins from its own package project. The dsh installation packages and the dependencies embedded in bundles are not on the profile's dependency tree, so Node's default `node_modules` lookup starting from the profile cannot find them.
+
+A developer must be able to restate the resolution rule in one sentence: which part is ordinary Node behavior, which part is dsh's intervention, and where that intervention ends. The previous runtime implementation hid the whole `$DSH_HOME/profiles/node_modules` layer for every package name and kept dedicated recognition code for historical directory layouts, so the rule could not be stated in one sentence and nobody could judge whether a given resolution result was expected.
+
+## Decision
+
+Module resolution inside a profile is an ordinary Node ancestor `node_modules` chain. dsh does exactly one thing: every package name in the runtime resolution computed at startup occupies the package directory position `$DSH_HOME/profiles/node_modules/`. When the chain reaches this layer, a package name with an entry is answered by the runtime resolution's package, and a package name without an entry sees the physical directory at this layer. Every other resolution behavior matches Node. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here.
+
+### Part 1: Resolution rules
+
+#### 1. The ancestor chain: Node's ancestor `node_modules` lookup
+
+The importer is the module file that issues the `import` or `require`. Node starts from the importer's directory, tries the `node_modules` under each directory level upward until the filesystem root, and then tries `NODE_PATH` and the global directories. The nearest layer wins; within one layer, matching is by package name only.
+
+Symlinks are followed to their real path per Node's default behavior. Once a module is loaded by its real path, the ancestor chain for its subsequent imports is the real path's ancestor chain, regardless of where it was linked from.
+
+The chain below defines the numbering that every table in this Note references. The importer is `$DSH_HOME/profiles/web/node_modules/my-plugin/index.js`:
+
+```text
+① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
+② $DSH_HOME/profiles/web/node_modules
+③ $DSH_HOME/profiles/node_modules
+④ $DSH_HOME/node_modules
+⑤ /node_modules
+```
+
+① holds the plugin's private dependencies, ② the packages the profile installed, ③ the interception layer where the runtime resolution occupies the package directories it has entries for, ④ the Harness home, and ⑤ the filesystem root followed by `NODE_PATH` and the global directories.
+
+#### 2. The interception layer: the runtime resolution occupies `$DSH_HOME/profiles/node_modules/`
+
+The interception layer is the first ancestor `node_modules` above the importer's profile directory (`$DSH_HOME/profiles/`). For every profile inside the tree this layer is `$DSH_HOME/profiles/node_modules`.
+
+Every package name in the runtime resolution occupies the package directory position `/` at this layer. For an installation package name such as `@deepseek-ai/dsh-tools`, the package directory at ③ is the copy of the running installation; an old link or old directory of the same name on disk is no longer a member of the chain. For a package name absent from the runtime resolution, the package directory at ③ is the physical content on disk.
+
+The order for one bare package name request: the ancestor chain first walks the layers inside the profile directory (① and ②), and when a candidate exists Node resolves inside that candidate and returns. On reaching ③, if the runtime resolution has an entry for that package name, Node resolves inside the package directory the entry records; a hit returns, and a missing file inside the package continues per Node's own handling of a "found package": CommonJS keeps looking for the subpath layer by layer from ④, ESM fails immediately. If the runtime resolution has no entry, Node looks at the physical directory at ③ and then moves on to ④.
+
+Node remains responsible for `exports`, `imports`, conditions, `main`, subpaths, extensions, caching, and error codes. When the selected package rejects a subpath through `exports`, the error is final; Node does not switch to another package of the same name.
+
+The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the runtime resolution, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's `node_modules` whose target lies inside `/.dsh-module-fallback/node_modules`, followed by that directory. pnpm-installed packages and every other symlink stay.
+
+#### 3. What the interception layer holds: the runtime resolution's scan contents
+
+The runtime resolution is computed once at profile startup and consists of three parts.
+
+- Installation closure: starting from the `package.json` of the currently running dsh package, a breadth-first traversal follows `dependencies` and `peerDependencies`; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The closure holds several hundred entries, roughly half in the `@deepseek-ai/` scope and half third-party libraries. These entries apply to every profile.
+- Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root.
+- Local package names: package names among the profile's direct dependencies that are already installed in `$DSH_HOME/profiles//node_modules`. They already sit at ② on the ancestor chain; recording them only saves one directory probe.
+
+The CLI derives the installation anchor from `import.meta.url`, which Node has already resolved to its real path; the Desktop Host builds it from its runtime directory, which is not a symlink. Both leave the installation root equal to its real directory, so bundle discovery and dependency traversal use the same location. During the traversal, every dependency level uses the real directory of its owning package as the lookup anchor for the next level and records the location of the manifest that declares it. After a hit, Node resolves from that declaring location and gets the same result the package's own internal imports get. Every entry records the package name, package directory, version, declaring location, and scope. Declared but uninstalled dependencies are skipped; the bundle package root itself does not become an entry.
+
+#### 4. Hook coverage
+
+The hook participates only when the importer is under `$DSH_HOME/profiles/**`. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside the tree, go straight to Node.
+
+The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same runtime resolution. The implementation lives in `packages/boot/app-boot/src/profile-resolution/resolver.ts`; runtime resolution construction lives in `packages/boot/app-boot/src/profile.ts`.
+
+### Part 2: Walkthroughs
+
+#### Table 1: Ancestor chain and interception for ESM and CommonJS across request forms
+
+The table uses the installation package `@deepseek-ai/dsh-tools` (has a runtime resolution entry) and the third-party library `left-pad` (no runtime resolution entry) as examples; `pkg` stands for either.
+
+| Request form | ESM | CommonJS | Position of the package name at ③ | When a file is missing inside the package |
+|---|---|---|---|---|
+| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Same as ESM; a candidate directory at ① or ② whose entry file is missing does not count as a candidate | With entry: the runtime resolution's package; without entry: the physical directory | A missing entry point under `exports` is an `exports` error, final |
+| Bare name `pkg`, package has no `exports` | As above, entry point from `main` or `index` | As above | As above | ESM fails, final; CommonJS continues looking for an entry point at the next layer |
+| Subpath `pkg/sub`, package has `exports` | The first `pkg` found decides; if `./sub` is not exported, `ERR_PACKAGE_PATH_NOT_EXPORTED` is final | Same as ESM | As above | Final; no other copy is searched |
+| Subpath `pkg/sub`, package has no `exports` | If the first `pkg` found has no `sub`, `ERR_MODULE_NOT_FOUND` is final | Tries `/pkg/sub` layer by layer: ① and ②, then the position of the package name at ③ (the runtime resolution's package directory when an entry exists), then ④ | As above | ESM final; CommonJS continues to the next layer, that is ④ |
+| `#alias` package-internal alias | After the owning manifest's `imports` maps it to a bare name, handled per the rows above; a mapping to a relative path goes straight to Node | Same as ESM, with conditions from the caller or the defaults | Same as the matching row | Same as the matching row |
+| `require.resolve(pkg, { paths })` | No such form | Applies the same rule to each item in `paths` independently: an item inside the profile tree walks ① and ②, ③, ④ from that item; an item outside the tree goes to Node; in the caller's order | As above | The item continues to its next layer, then the next item takes its turn |
+| Package self-reference (the importer's package imports by its own `name`) | Goes to Node with the original importer kept | Same as ESM | Not involved | Not applicable |
+| Relative / absolute / URL / builtin | Goes to Node | Goes to Node | Not involved | Not applicable |
+
+After a runtime resolution hit, the `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH_NOT_EXPORTED` errors Node reports replace the internal declaring location with the original importer; the CommonJS require stack also drops the internal anchor.
+
+#### Table 2: Interception position for the Web and Desktop profiles
+
+| profile | Directory | Interception layer | Difference from web |
+|---|---|---|---|
+| web | `$DSH_HOME/profiles/web` | `$DSH_HOME/profiles/node_modules` | Baseline |
+| desktop | `$DSH_HOME/profiles/desktop` | `$DSH_HOME/profiles/node_modules` | None. Same tree, same layer; the Desktop Host runs the same resolver in Node mode |
+| Out-of-tree profile | Any directory `loadProfileDirectory` accepts | The first ancestor `node_modules` above that directory | Same rule; no product currently uses an out-of-tree profile, only unit tests cover it |
+
+#### Table 3: Ancestor chain when a plugin is linked outside the tree
+
+| Import source → target | Hook participates | Result |
+|---|---|---|
+| profile → linked plugin | Participates up to ② | Node follows the symlink at ②; the plugin loads by its real path |
+| Linked plugin → its own third-party dependencies | Does not participate | The real path's ancestor chain; uses the version in the developer's own `node_modules` |
+| Linked plugin → dsh installation packages | Does not participate | The copy in the developer's own `node_modules`; `ERR_MODULE_NOT_FOUND` when absent. Part 3 describes how to make it the same copy as the running dsh |
+
+### Part 3: Developer integration guide
+
+#### 1. Normal installation
+
+`dsh plugin --profile add ` forwards its argument to pnpm, which installs inside the profile directory with a hoisted layout. The profile's `pnpm-workspace.yaml` sets `autoInstallPeers: false`, so dsh packages a plugin declares as peers are not installed into the profile; the interception layer supplies the copy from the running installation, and the plugin and dsh share one module instance.
+
+The plugin's own third-party dependencies are hoisted to `$DSH_HOME/profiles//node_modules` and found at ② on the ancestor chain. When a name collides with the installation closure, the nearest wins and the plugin uses the version it declared. The `file:` form copies the local checkout into the profile; resolution afterwards is identical to a registry installation.
+
+#### 2. Development mode: the plugin repository is outside the profile tree
+
+`npm link`, or a bare directory path such as `dsh plugin add ../my-plugin` (which pnpm treats as `link:`), makes the entry in the profile a symlink to the plugin repository. The plugin loads by its real path and the hook takes no part in its imports. The plugin repository's own dependency tree must resolve dsh installation packages to the same copy as the running dsh; both layouts below satisfy this.
+
+- Layout A: the plugin repository installs `@deepseek-ai/dsh` and the `@deepseek-ai/dsh-*` peers the plugin uses as devDependencies and starts with the dsh in that repository, for example `pnpm exec dsh --profile `. The running installation is the copy on the plugin's ancestor chain, so the runtime resolution and the real paths agree.
+- Layout B: the plugin repository links the dsh packages it uses to the local dsh source repository (`link:../deepseek-harness/packages/core/tools` or `pnpm link`) and starts `pnpm dsh --profile ` from the source repository. Both sides load the same copy from the source repository.
+
+Not provided and not recommended: having the hook proxy installation packages for real paths linked out of the tree, which requires tracking every real root entered from the profile, something Node itself does not do for developers either; and starting with a dsh installed elsewhere while the plugin repository installs its own dsh devDependencies, which produces two module instances and splits both `Symbol()` keys and `instanceof` checks.
+
+## Alternatives considered
+
+**Let the runtime resolution act only as a fallback after the whole ancestor chain is exhausted.** The ancestor chain would first read a leftover historical installation link at ③ and use it, which conflicts with "installation packages come from the running installation". The runtime resolution must occupy the positions of installation package names at ③.
+
+**Treat the runtime resolution as a whole layer inserted into the ancestor chain instead of occupying package directories at ③.** The two differ in exactly one place: when CommonJS hits a runtime resolution package and the subpath is missing, the former would go back and read the old copy of the same name at ③, while the latter goes straight to ④ per Node's handling of a "found package". Occupying the package directory matches Node semantics and needs no extra check for ③.
+
+**Give installation closure entries absolute priority over nearer copies.** The closure contains 281 third-party libraries. A plugin's private versions of `zod`, `yaml`, and others would be overridden by the versions in the installation closure, breaking the nearest-wins rule shared with Node. The supported installation flow no longer installs dsh peers into the profile, so overriding is not needed to guarantee a single instance.
+
+**Recognize and bypass symlinks in the `.dsh-module-fallback` layout at resolve time.** This keeps dedicated logic in the lookup path for directories that are no longer produced, and every resolution pays for it. Removing those projections once at profile load reaches the same result: after a bundle is deselected while it stays installed, its projected plugin no longer shadows the same-named plugin the runtime resolution selects from another bundle.
+
+**Hide the whole physical directory that holds the interception layer.** This conflicts with "all other resolution matches Node": non-installation packages placed in `$DSH_HOME/profiles/node_modules` would become invisible to every profile.
+
+**Extend the hook to the real directories of plugins linked outside the tree.** This requires tracking every real root entered from the profile during resolution and rewriting results for some package names under those directories; Node does this for no linked-out package, and the two layouts in Part 3 do not need it.
+
+## Verification
+
+- The table-driven matrix in [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts): importers are the profile root and a plugin inside the profile; package names are an installation entry, a bundle-only entry, and a name outside the table; every presence combination of the four layers ①②③④, with ② and ③ each in two forms, a real directory and a symlink pointing elsewhere; every cell asserts that ESM import, CommonJS require, `require.resolve`, and the `packageDir` metadata land in the same directory.
+- Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile.
+- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts), under src and lib launches and under plain-directory and npm-link layouts, places an old `@deepseek-ai/dsh-tools` link at ③ and an external package with its embedded dependency at ③, and asserts that Tools and AgentLoop share one module instance, that the external package resolves along its real path, and that every file and link target stays byte-identical.
+
+## Consequences
+
+Gained: startup writes nothing to disk; installation packages always come from the running installation; all other resolution matches Node, so developers can predict results with their Node knowledge; no recognition code exists for any historical directory layout.
+
+Paid: third-party libraries in the installation closure still occupy package directories at ③, so a profile plugin that declares a third-party library as a peer or omits the declaration gets the version dsh uses internally, as in the disk-link era; plugins should declare third-party libraries as dependencies. Plugins linked outside the tree cannot obtain installation packages through the hook and must align themselves using the layouts in Part 3. Consumers that bypass the hook, namely child processes a plugin spawns itself, third-party Workers, and external tools run inside the profile directory, can no longer find installation packages through disk links in `$DSH_HOME/profiles/node_modules`. The resolver continues to depend on supported Node Internal interfaces.
diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md
new file mode 100644
index 0000000000..72a9e876a4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md
@@ -0,0 +1,141 @@
+# Agent Note: profile 解析的主线祖先链与拦截层
+
+Status: implemented
+
+[English](2026-09-19-profile-resolution-lookup-order.md) | 中文
+
+## Problem
+
+profile 从自己的包项目加载插件。dsh 本体包和 bundle 内嵌的依赖不在 profile 的依赖树上,Node 默认的 `node_modules` 查找从 profile 出发找不到它们。
+
+解析规则需要能被开发者一句话复述:哪一段是普通 Node 行为,哪一段是 dsh 的干预,干预的范围到哪里为止。此前的运行时实现把 `$DSH_HOME/profiles/node_modules` 整层对所有包名隐藏,又为历史目录形状保留了专门的识别代码,规则无法用一句话说清,也无法判断某次解析结果是否符合预期。
+
+## Decision
+
+profile 内模块的解析是一条普通的 Node 祖先 `node_modules` 链。dsh 只做一件事:启动时算出的 runtime resolution 中的每个包名,占据 `$DSH_HOME/profiles/node_modules/<包名>` 这个包目录的位置。链走到这一层时,有条目的包名由 runtime resolution 的包回答,没有条目的包名看这一层的物理目录。除此之外的一切解析行为与 Node 一致。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。
+
+### 一、解析规则
+
+#### 1. 主线:Node 的祖先 `node_modules` 链
+
+importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 importer 所在目录开始,逐层向上尝试每一级目录下的 `node_modules`,直到文件系统根,再尝试 `NODE_PATH` 与全局目录。最近的一层优先;同一层内只按包名匹配。
+
+软链接按 Node 默认行为跟随到真实路径。一个模块一旦以真实路径加载,它后续 import 的主线就是真实路径的祖先链,与它被链接到哪里无关。
+
+下面这条链是全文表格引用的编号。importer 为 `$DSH_HOME/profiles/web/node_modules/my-plugin/index.js`:
+
+```text
+① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
+② $DSH_HOME/profiles/web/node_modules
+③ $DSH_HOME/profiles/node_modules
+④ $DSH_HOME/node_modules
+⑤ /node_modules
+```
+
+① 是插件私有依赖,② 是 profile 安装的包,③ 是拦截层,runtime resolution 占据其中有条目的包目录,④ 是 Harness home,⑤ 是文件系统根,随后是 `NODE_PATH` 与全局目录。
+
+#### 2. 拦截层:runtime resolution 占据 `$DSH_HOME/profiles/node_modules/<包名>`
+
+拦截层是 importer 所属 profile 目录(`$DSH_HOME/profiles/`)之上的第一个祖先 `node_modules`。树内所有 profile 的这一层都是 `$DSH_HOME/profiles/node_modules`。
+
+runtime resolution 中的每个包名,在这一层上占据 `<拦截层>/<包名>` 这个包目录的位置。对 `@deepseek-ai/dsh-tools` 这样的本体包名,③ 上的包目录就是运行中安装的那一份;磁盘上同名的旧链接或旧目录不再是链上的成员。对 runtime resolution 没有的包名,③ 上的包目录就是磁盘上的物理内容。
+
+一次裸包名请求的顺序:主线先走完 profile 目录内部的各层(①②),有候选就交给 Node 在候选内解析并返回。走到 ③ 时,runtime resolution 有该包名的条目,Node 就在条目记录的包目录内解析;找到就返回,包内缺文件则按 Node 自身对"已找到包"的处理继续,CommonJS 从 ④ 继续逐层找子路径,ESM 直接报错。runtime resolution 没有条目,Node 就看 ③ 的物理目录,再到 ④。
+
+Node 仍然负责 `exports`、`imports`、conditions、`main`、子路径、扩展名、缓存与错误码。被选中的包因 `exports` 拒绝某个子路径时报错终局,不会换另一个同名包。
+
+拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 runtime resolution 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile `node_modules` 下目标位于 `/.dsh-module-fallback/node_modules` 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。
+
+#### 3. 拦截层里有什么:runtime resolution 的扫描内容
+
+runtime resolution 在 profile 启动时一次算出,由三部分组成。
+
+- installation 闭包:从当前运行的 dsh 包的 `package.json` 出发,沿 `dependencies` 与 `peerDependencies` 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。闭包有数百条,约一半在 `@deepseek-ai/` 作用域,一半是第三方库。这些条目对所有 profile 生效。
+- bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。
+- 本地包名:profile 直接依赖中已经安装在 `$DSH_HOME/profiles//node_modules` 的包名。它们本来就在主线 ② 上,记录下来只为免去一次目录探测。
+
+CLI 的安装锚点来自 `import.meta.url`,Node 已对它做过 realpath;Desktop Host 从其运行时目录拼出锚点,该目录不是软链接。两者的安装根目录都等于真实目录,bundle 发现与依赖遍历使用同一个位置。遍历中每一层依赖都以所属包的真实目录作为下一层的查找锚点,并记录声明它的 manifest 位置。命中后 Node 从这个声明位置解析,得到与该包自己内部 import 相同的结果。每个条目记录包名、包目录、版本、声明位置与作用域。已声明但未安装的依赖跳过;bundle 包根自身不成为条目。
+
+#### 4. hook 覆盖范围
+
+hook 只在 importer 位于 `$DSH_HOME/profiles/**` 时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在树外的一切请求,直接交给 Node。
+
+ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 runtime resolution。实现位于 `packages/boot/app-boot/src/profile-resolution/resolver.ts`,runtime resolution 的构造位于 `packages/boot/app-boot/src/profile.ts`。
+
+### 二、预演
+
+#### 表 1:ESM 与 CommonJS 在各类请求下的主线与拦截
+
+下表以本体包 `@deepseek-ai/dsh-tools`(runtime resolution 有条目)和第三方库 `left-pad`(runtime resolution 无条目)为例;`pkg` 泛指两者。
+
+| 请求形态 | ESM | CommonJS | ③ 上该包名的位置 | 包内缺文件时 |
+|---|---|---|---|---|
+| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | 同 ESM;①② 的候选目录缺入口文件时不算候选 | 有条目:runtime resolution 的包;无条目:物理目录 | 有 `exports` 的入口缺失是 `exports` 错误,终局 |
+| 裸包名 `pkg`,包无 `exports` | 同上,入口取 `main` 或 `index` | 同上 | 同上 | ESM 终局报错;CommonJS 从下一层继续找入口 |
+| 子路径 `pkg/sub`,包有 `exports` | 第一个找到的 `pkg` 决定;`./sub` 未导出则 `ERR_PACKAGE_PATH_NOT_EXPORTED` 终局 | 同 ESM | 同上 | 终局,不找别的副本 |
+| 子路径 `pkg/sub`,包无 `exports` | 第一个找到的 `pkg` 内没有 `sub` 则 `ERR_MODULE_NOT_FOUND` 终局 | 逐层尝试 `<层>/pkg/sub`:①②,然后 ③ 上该包名的位置(有条目即 runtime resolution 的包目录),然后 ④ | 同上 | ESM 终局;CommonJS 继续下一层,即 ④ |
+| `#alias` 包内别名 | 所属 manifest 的 `imports` 映射到裸包名后,按上面各行处理;映射到相对路径则直接交 Node | 同 ESM,conditions 取调用方传入或默认值 | 同对应行 | 同对应行 |
+| `require.resolve(pkg, { paths })` | 无此形式 | 对 `paths` 中每一项独立套用同一规则:项在 profile 树内则从该项出发走 ①②、③、④;项在树外交 Node;按调用方给出的顺序 | 同上 | 该项继续下一层,再轮到下一项 |
+| 包自引用(importer 所属包按自己的 `name` import) | 交 Node,保留原 importer | 同 ESM | 不参与 | 不适用 |
+| 相对 / 绝对 / URL / builtin | 交 Node | 交 Node | 不参与 | 不适用 |
+
+命中 runtime resolution 后,Node 报出的 `ERR_MODULE_NOT_FOUND` 与 `ERR_PACKAGE_PATH_NOT_EXPORTED` 会把内部的声明位置替换回原 importer;CommonJS 的 require stack 也去掉内部锚点。
+
+#### 表 2:Web 与 Desktop 两种 profile 的拦截位置
+
+| profile | 目录 | 拦截层 | 与 web 的差异 |
+|---|---|---|---|
+| web | `$DSH_HOME/profiles/web` | `$DSH_HOME/profiles/node_modules` | 基准 |
+| desktop | `$DSH_HOME/profiles/desktop` | `$DSH_HOME/profiles/node_modules` | 无。同一棵树、同一层;Desktop 的 Host 以 Node 模式运行同一套 resolver |
+| 树外 profile | `loadProfileDirectory` 接受的任意目录 | 该目录之上的第一个祖先 `node_modules` | 规则相同;当前没有产品使用树外 profile,只有单元测试覆盖 |
+
+#### 表 3:插件被 link 到树外时的主线
+
+| import 来源 → 目标 | hook 是否参与 | 结果 |
+|---|---|---|
+| profile → 链接的插件 | 参与到 ② 为止 | ② 的软链接被 Node 跟随,插件以真实路径加载 |
+| 链接插件 → 它自己的第三方依赖 | 不参与 | 真实路径的祖先链,使用开发者自己 `node_modules` 里的版本 |
+| 链接插件 → dsh 本体包 | 不参与 | 开发者自己 `node_modules` 里的副本;没有则 `ERR_MODULE_NOT_FOUND`。让它与运行中的 dsh 是同一份的做法见第三章 |
+
+### 三、开发者接入指引
+
+#### 1. 正常安装
+
+`dsh plugin --profile add <包名 | git spec | file:../本地检出>` 把参数转发给 pnpm,在 profile 目录内以 hoisted 布局安装。profile 的 `pnpm-workspace.yaml` 设置 `autoInstallPeers: false`,插件声明为 peer 的 dsh 包不会被装进 profile,由拦截层提供当前运行安装的那一份,插件与 dsh 共用同一个模块实例。
+
+插件自己的第三方依赖被 hoist 到 `$DSH_HOME/profiles//node_modules`,按主线 ② 找到。与 installation 闭包同名时最近者优先,插件使用自己声明的版本。`file:` 形式把本地检出复制进 profile,随后的解析与注册表安装完全相同。
+
+#### 2. 开发模式:插件仓库在 profile 树外
+
+`npm link`,或 `dsh plugin add ../my-plugin` 这类裸目录路径(pnpm 按 `link:` 处理),会让 profile 里的条目成为指向插件仓库的软链接。插件以真实路径加载,hook 不参与它的 import。dsh 本体包必须由插件仓库自己的依赖树解析到与运行中 dsh 同一份,两种布局都满足这一点。
+
+- 布局 A:插件仓库把 `@deepseek-ai/dsh` 以及插件用到的 `@deepseek-ai/dsh-*` peer 装成 devDependency,并用该仓库里的 dsh 启动,例如 `pnpm exec dsh --profile `。运行中的安装就是插件祖先链上的那一份,两者的 runtime resolution 与真实路径一致。
+- 布局 B:插件仓库把用到的 dsh 包 link 到本机的 dsh 源码仓(`link:../deepseek-harness/packages/core/tools` 或 `pnpm link`),并从源码仓启动 `pnpm dsh --profile `。两边加载的都是源码仓里的同一份。
+
+不提供、不建议的做法:由 hook 为 link 出去的真实路径代理本体包,这需要追踪从 profile 进入的每个真实根目录,Node 自身也不替开发者做这件事;用别处安装的 dsh 启动,同时插件仓库另装一套 dsh devDependency,这会产生两个模块实例,`Symbol()` 键与 `instanceof` 判断都会分裂。
+
+## Alternatives considered
+
+**让 runtime resolution 只在主线全部走完后兜底。** 主线会先在 ③ 读到历史残留的本体链接并使用它,与"本体以当前运行的安装为准"冲突。runtime resolution 必须占据 ③ 上本体包名的位置。
+
+**把 runtime resolution 当作插入主线的一整层,而不是占据 ③ 上的包目录。** 两者只在一处不同:CommonJS 命中 runtime resolution 的包后子路径缺失时,前者会再回头读 ③ 上同名的旧副本,后者按 Node 对"已找到的包"的处理直接到 ④。占据包目录与 Node 语义一致,也不需要为 ③ 保留任何额外判断。
+
+**让 installation 闭包条目绝对优先于更近的副本。** 闭包含 281 个第三方库。插件私有的 `zod`、`yaml` 等版本会被安装闭包里的版本覆盖,破坏与 Node 一致的最近者优先。受支持的安装流程已经不把 dsh 的 peer 装进 profile,不需要再用覆盖来保证单实例。
+
+**在解析时识别 `.dsh-module-fallback` 形状的软链接并绕过。** 这会为不再产生的目录在查找路径里保留专门逻辑,每次解析都要付出判断。在 profile 加载时一次性删除这些投影能得到同样的结果:一个 bundle 被停用但仍装着时,它投影出来的插件不再遮住 runtime resolution 从另一个 bundle 选出的同名插件。
+
+**把拦截层所在的物理目录整层隐藏。** 与"其余解析与 Node 一致"冲突:放在 `$DSH_HOME/profiles/node_modules` 的非本体包会对所有 profile 不可见。
+
+**让 hook 覆盖 link 到树外的插件真实目录。** 需要在解析时追踪每个从 profile 进入的真实根目录,并对这些目录下的部分包名改写结果;Node 不为任何 link 出去的包做这种事,第三章的两种布局不需要它。
+
+## Verification
+
+- [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) 中的表驱动矩阵:importer 取 profile 根与 profile 内插件,包名取 installation 条目、bundle-only 条目与表外包名,①②③④ 四层的每一种有无组合,②③ 各含真目录与指向别处的软链接两种形态;每格断言 ESM import、CommonJS require、`require.resolve` 与 `packageDir` 元数据落在同一个目录。
+- 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。
+- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)在 src 与 lib 两种启动、普通目录与 npm-link 两种布局下,放置 ③ 的旧 `@deepseek-ai/dsh-tools` 链接、③ 的外部包及其内嵌依赖,断言 Tools 与 AgentLoop 共用同一模块实例、外部包沿真实路径解析、所有文件与链接目标逐字节不变。
+
+## Consequences
+
+买到的:启动不写盘;本体包永远来自当前运行的安装;其余解析与 Node 一致,开发者可以用 Node 的知识预测结果;没有针对任何历史目录形状的识别代码。
+
+付出的:installation 闭包里的第三方库仍占据 ③ 上的包目录,profile 插件把第三方库声明成 peer 或漏声明时会拿到 dsh 内部使用的版本,与磁盘链接时代相同,插件应把第三方库声明成 dependency;link 到树外的插件不能通过 hook 拿到本体包,必须按第三章的布局自行对齐;不经过 hook 的消费者,即插件自己 spawn 的子进程、第三方 Worker、在 profile 目录里运行的外部工具,不再能通过 `$DSH_HOME/profiles/node_modules` 的磁盘链接找到本体包;resolver 继续依赖受支持的 Node Internal 接口。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
similarity index 50%
rename from .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
index f6a8425e96..ea121beb8c 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
@@ -1,6 +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-08-stable-room-reading-under-hidden-split-controls.md
-2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba
-2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
+2026-09-19-projection-cache-listing-identity-and-cached-rows.md: 61b82e5003ba4b634230941f5d3df65e65491e25
+2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md: ef49d90bc68ca501f7496adbac1d1aa8a5d706a7
diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
new file mode 100644
index 0000000000..61b82e5003
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
@@ -0,0 +1,166 @@
+# Agent Note: Projection cache read-only face matches the lifecycle identity; the client store separates cached and sequenced rows
+
+Status: implemented
+
+English | [中文](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)
+
+## Problem
+
+After a Host process restart, every forked session (`SessionHeader.isSeeded === true`) shows no title in the sidebar session list, `@` reference completion shows only the session id, and list ordering degrades to creation time. Opening the session once restores all of it. On one development machine on 2026-09-19, 44 of 241 projcache records were seeded, all 44 carried a valid `title` row, and the list never read any of them.
+
+This is neither cache corruption nor a version mismatch. The projcache record's domain version is 7 and its `identity.formatVersion` is 4; those are the cache's own on-disk format generation and the Session log format generation the fold ran under, and both are current. The defect is on the read path.
+
+### Mechanism
+
+A cache record (record format and predecessor recovery: [Projection-cache predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md)) is bound to a lifecycle identity: `formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`, matched by `identityMatches` on full equality. Since #3346, `inheritedEventCount` (the length of the event prefix a fork inherits, the cut below) no longer appears in the logical header: the header keeps only the `isSeeded` bit and the exact cut follows the body. Since Session format v2 (#3398) the physical header line no longer stores `seedLength` either; the reader derives the cut from the seq of the `session/end-seed {inherited: true}` marker in the body.
+
+A header-only read therefore cannot obtain the cut: the JSONL backend's `fromHeaderLine` hard-codes `inheritedEventCount: 0` for header-only reads, and `SessionPersistenceSnapshot` carries only the header, the revision, and an optional eventCount. All three header-only cache consumers grew the same guard:
+
+| Consumer | Guard | Fallback | Consequence |
+|---|---|---|---|
+| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | none | no title, no `sessionListMetadata`; blank falls back to `false`, ordering falls back to `createdAt` |
+| `packages/context/session-reference/src/index.ts` `projectedLabels` | same | none | `@` completion labels by id; the title cannot be searched |
+| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | same | `observeSession` reads the body | correct result, one body read per seeded child |
+
+When the guard landed (#3346, 2026-09-01), `list.ts` still had `probeSmallCold`: on a cache miss it read the body when the log file was at most `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` bytes. Its purpose was blank-session detection; it covered only logs under 1KB and never applied to an ordinary fork. #3400 (2026-09-03) removed it and the list returned to metadata plus cache only. #4320 (2026-09-19) made forking a first-class feature (`packages/core/session/src/fork.ts`, fork at any seq); every fork is `isSeeded: true` with an exact cut, seeded sessions went from a handful to dozens, all of them cold after a restart, and the gap became visible at once.
+
+### The cache's two uses and where validation lives
+
+The read and write faces of `SessionProjectionCache` and their callers:
+
+| Face | What it does with the record | Writes back | Callers |
+|---|---|---|---|
+| `write(session)` | writes the checkpoint at three mandatory points plus a throttle | yes | the cache's own listeners |
+| `hydratePrepared(session, events)` | uses `rows` as the fold starting point and applies events from `row.seq + 1`; the result lands in live cells | the next checkpoint | `session-query/src/observation.ts` |
+| `coldSnapshot(meta, cut, events)` | fold starting point plus write-back | yes | no production caller |
+| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`: each row passes `ver` and `stateSchema`, is `view`ed, and returned | no | `list.ts`, `session-reference`, `list-children` |
+| `cachedPredecessorTitle(meta, cut)` | the same, `title` only, allowing an older `formatVersion` | no | `list.ts` |
+
+Identity validation lives entirely inside the cache (`recordFor` → `identityMatches`; `viewCheckpoint` checks `ver` and schema per row). `list.ts` validates nothing itself; it merely declines to call when it cannot supply a cut.
+
+spec.ts and the README describe the purpose of the identity check with one verb: preventing "seed state folded from an unrelated log", "cannot seed the caller". That guards the fold face. The read-only face has never seeded anything.
+
+### The client store cannot guarantee that connected data replaces hints
+
+Each session has exactly one `ProjectionValueStore` on the client (`manager.projectionStores`; its merge rules were recorded in [Session observations and projection-owned client state](2026-08-25-session-observations-and-projection-owned-client-state.md)). The block delivered by the list, the history first-page baseline on open, the control baseline, push frames, and rename results all write into that one object, and `useProjection` reads it. All writers are peers under one higher-seq-wins rule: `apply` drops a new value when `seq <= row.seq`, and `seed` clears omitted keys only when `row.seq <= cut`. A list hint whose seq is equal or higher survives untouched after the session is opened. The hint's seq comes from a disk record; after a crash-repair truncation it can be numerically higher than the connected cursor, which is exactly the case where the cache is wrong and the connection is right. Comparing seqs against a hint is the wrong tool.
+
+## Decision
+
+### Two read faces; the cut is compared only on the fold face
+
+| Face | Identity | Who can supply it | Methods |
+|---|---|---|---|
+| read-only face | lifecycle identity: `formatVersion + createdAt + cwd + isSeeded`, all from the header | any header-only caller | `cachedSnapshot(header, keys?)`, `cachedPredecessorTitle(header)` |
+| fold face | lifecycle identity plus `inheritedEventCount` | callers holding the Session or its body | `hydratePrepared`, `write`, `coldSnapshot`, all through `recordFor` |
+
+The two read-only methods no longer take a cut parameter. `inheritedEventCount` is still written into every record and the fold face still compares it on full equality; the original design expectation, that records folded under different fork cuts never seed each other, is unchanged.
+
+In the block the read-only face returns, `asOfSeq` is the lowest watermark among the served rows, the stored record's own position. A header can vouch neither for the cut nor for the comparability of that watermark with the current log, so comparability is not expressed by this number: the Session list adds an independent field `kind` to each summary's `projections` block (`SessionProjectionHints`). A block a cold session's row viewed from projcache is `cached`; a block the Host's live registry computed for an attached session is `sequenced`. The two fields are independent facts: `kind` names the sequence space `asOfSeq` belongs to, and `asOfSeq` is the watermark within that space.
+
+### Why the read-only face may skip the cut
+
+- Within one `formatVersion`, a log's cut is determined by `(id, createdAt, cwd, isSeeded)`: it is fixed at fork creation, and only a cardinality-changing format migration can alter it, which necessarily bumps `formatVersion`. The cut is a derived fact of the lifecycle, not an independent coordinate.
+- What the read-only face actually did until now: unseeded callers passed the constant 0 (true by construction) and seeded callers never called. The cut was never compared against any real fact on this face. Removing it changes the rejection set not at all: a record from another lifecycle (any of the four fields differing) is still rejected, unseeded results are identical, and seeded lifecycles go from unaddressable to addressable.
+- The read-only face never seeds and never writes back; its output is a pure function of the stored record, the cut plays no part in it, and it flows nowhere.
+- The one new exposure: a record whose four fields all match but whose cut differs. The system cannot produce it; only copying a session directory by hand and editing files can. Even then the list shows that record's values until the session is opened, and nothing enters any fold.
+
+### Consumers
+
+| Location | Change |
+|---|---|
+| `list.ts` `projectionsFor` | drop the `isSeeded` branch and `SessionLogOffset(0)`; every cold row reads `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
+| `session-reference` `projectedLabels` | same symptom, same change |
+| `list-children.ts` `resolveColdIdentity` | follows the signature only; the `!header.isSeeded` guard and the body fallback stay, because it needs the cut to classify a seq as inherited or owned, which is fold semantics |
+
+### The client store separates cached and sequenced rows
+
+Rows in `ProjectionValueStore` come in two kinds, named for the property the rule depends on: whether the value carries a seq comparable within this connection.
+
+| Row | Meaning | Carries |
+|---|---|---|
+| `cached` | viewed from the persisted checkpoint without a Session; its seq is not comparable with this connection | `value` only |
+| `sequenced` | computed by the Host for this Session within this connection; seqs share one space | `value` and `seq` |
+
+Rules:
+
+| Write | Against a `cached` row | Against a `sequenced` row |
+|---|---|---|
+| `applyCached(values)` | replaces | ignored |
+| `seed(baseline)` | discards every `cached` row first, then writes the block; omitted keys clear under `seq <= cut` | higher-seq-wins |
+| `apply(key, value, seq)` | replaces | higher-seq-wins |
+
+Seq comparison happens only within one Host connection: `handleConnected` clears the whole store, then the list writes cached rows again and opening a session seeds again. From cached to sequenced is an unconditional replacement without any seq comparison.
+
+Writer classification:
+
+| Entry | Row kind |
+|---|---|
+| the `projections` block of each summary in the `session.list` response (`manager.refreshList`) | by the block's `kind`: `cached` for a cold session, `sequenced` for a live one |
+| the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` |
+| the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced |
+| the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced |
+| the `session.projections` result of `refreshProjections` (a body observation) | sequenced |
+| push frames (the `projection` frame handled by `manager`) | sequenced |
+| the `title` after a successful rename (`session.ts`) | sequenced |
+
+The `seed` and `apply` signatures are unchanged. The client routes by `kind`: a `sequenced` block goes through `apply` key by key, a `cached` block through `applyCached`, which does not read its `asOfSeq`.
+
+### What stays untouched
+
+- The Session format, the physical header line, `fromHeaderLine`, persistence, `SessionPersistenceSnapshot`.
+- Hydration and the checkpoint write path; `recordFor` keeps full equality on all five fields.
+- `coldSnapshot` stays; having no production caller is not a reason to delete it here.
+- Old records (domain v4/v5, lacking `isSeeded` and `inheritedEventCount`) keep missing for seeded sessions until the session is opened and rewritten as a v7 record.
+- The list still delivers every row with a wire view (`contextBreakdown`, `turnOutline`, and so on); narrowing that is out of scope.
+
+### Worst case, walked through
+
+1. The list serves a wrong record; the sidebar shows a wrong title, held in a cached row.
+2. The session is opened; for the instant before the first-page baseline arrives the wrong value is still shown. Every pre-population has this window.
+3. The first-page baseline `seed`s: every cached row is discarded unconditionally, then the block is written. Nothing of the wrong value survives, whatever its seq.
+4. A later list refresh writes cached values that hit sequenced rows and are ignored; the wrong value does not come back.
+
+## Alternatives considered
+
+**Write the cut back into the physical header line.** Header-only reads would obtain the cut directly and the read-only face would keep its identity. The cost is another Session format generation right after V4 landed, and reversing #3346's decision that the exact cut does not pose as body-free metadata. Rejected: the header is not changed for this.
+
+**An index of each session's cut maintained by persistence or session-query.** A new durable index with its own consistency maintenance, serving one display read. Rejected.
+
+**Restore a bounded body probe.** The `probeSmallCold` idea #3400 removed, or a client-side asynchronous `refreshProjections` for visible seeded sessions. It breaks the list's zero-I/O principle, and the historical 1KB threshold shows it never covered an ordinary fork. Rejected.
+
+**Only the `asOfSeq: -1` sentinel, without store tiers.** One server-side change makes hints always lose under the seq rule. It rests on a convention: when a hint's seq equals or exceeds the baseline cut (crash-repair truncation), `apply` keeps the old row and the wrong value survives until the next frame. The user requires connected data to replace hints unconditionally, so the rule belongs in the store rather than in a seq convention.
+
+**Express cached through `asOfSeq: -1` and let the client route on the sentinel.** The read-only face always emits `-1` and the client treats every list block as cached. Rejected: one field would carry two meanings, and the second could only be inferred by convention; a live session's list block carries a real seq comparable within the connection, and treating every block as cached demoted those too. The PR review reproduced the regression: a delayed control baseline at a lower cut overwrote a newer list value. The independent field `kind` replaces the sentinel, and `asOfSeq` keeps each source's own watermark.
+
+**Remove the cut from the cache identity entirely.** The fold face needs it: `restore` continues applying from the cached row's state, and `schedule`, `subagentCatalog`, `permissions.seeded`, and owned/inherited classification encode the cut; an error would be written back and persisted. Rejected.
+
+**Naming the row kinds `hint` / `authoritative`.** Those words speak of trust, while the rule depends on seq comparability. Renamed `cached` / `sequenced` so the names state the property the rule uses.
+
+## Consequences
+
+Bought:
+
+- After a restart, forked sessions immediately show their title, `sessionListMetadata` (blank, lastPromptAt), and every other cached wire value in the sidebar, ordered by the last prompt time.
+- `@` completion shows and searches forks by title.
+- Connected data replaces list hints unconditionally, independent of seq ordering.
+- The two levels of the cache identity have names: the lifecycle identity answers "is this the same log", the fold identity answers "may this continue a fold".
+
+Paid:
+
+- `cachedSnapshot` and `cachedPredecessorTitle` change signature; three callers change with them.
+- `SessionProjectionHints` gains the required field `kind`; every producer of a list summary and every test fixture that builds one carries it.
+- A hand-crafted record with the same four fields and a different cut is displayed in the list until the session is opened.
+- Keys the baseline omits clear together with their hints: when a Host does not mount `schedule`, the schedule mark the list hinted disappears after the session opens. Under "connected data is the truth" this is the correct behavior.
+- Old records still miss for seeded sessions until an open rewrites them.
+
+## Testing
+
+- `session-projection-cache/tests/cache.spec.ts`: a seeded cold header obtains every version-matching row through `cachedSnapshot(header)` at the row's watermark; an unseeded header is refused for the same id's seeded record; `coldSnapshot` continues from the row when the cut matches, refolds the whole log when it differs, and throws for an unseeded call with a nonzero cut; `cachedPredecessorTitle(header)` serves only `title` from a seeded record of an older `formatVersion`; rows with differing watermarks form one block whose `asOfSeq` is the lowest row.
+- `session-projection-cache/tests/fixtures.spec.ts`: archived v3 to v6 records still expose only the predecessor title; lineage-less archives still miss for seeded callers.
+- `api/session-controller/tests/session-cold.host.spec.ts`: a seeded cold summary carries `kind: 'cached'`, `title`, and `sessionListMetadata`, `updatedAt` takes `lastPromptAt`, the cache is queried, and the body is not read.
+- `api/session-controller/tests/session-projections.host.spec.ts`: a real fork made through `sessions.fork` is checkpointed into projcache, the whole Context is disposed, and a Host restarted over the same storage root reads the `kind: 'cached'` `title` and `sessionListMetadata` through `session.list` from the header alone; `inspect` and `open` are never called.
+- `api/session-controller/tests/projection-store.client.spec.ts`: `applyCached` fills empty keys only and never displaces a sequenced row; every sequenced write (including a frame at cursor `-1` and a baseline at a lower cut) replaces cached rows; a baseline discards all cached rows before clearing omitted keys; face subscribers are notified on cached fills and their discard. Manager path: a `cached` list block at any watermark is replaced by a control baseline at a lower cut, and a later list refresh cannot bring it back; a `sequenced` list block merges under higher-seq-wins, so a delayed baseline at a lower cut neither overwrites nor clears it while a higher-seq frame still advances it.
+- `api/session-controller/tests/manager.client.spec.ts`: a `cached` `api-session/added` block is replaced by a control baseline at the same cursor; a `cached` list block does not displace an existing sequenced title.
+- `api/session-controller/tests/inbox-projection.client.spec.ts`: unchanged; a live session's `sequenced` list block still outranks a delayed control baseline at a lower cut.
+- `context/session-reference/tests/session-reference.spec.ts`: a seeded cold session is labeled and searchable by its cached title, a session without a cache record is still labeled by id, and neither reads a log.
+- `subagent/subagent/tests/list-children.spec.ts`: unchanged; seeded children still go through body observation.
diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md
new file mode 100644
index 0000000000..ef49d90bc6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md
@@ -0,0 +1,166 @@
+# Agent Note: Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行
+
+Status: implemented
+
+[English](2026-09-19-projection-cache-listing-identity-and-cached-rows.md) | 中文
+
+## Problem
+
+Host 进程重启后,所有 fork 出来的会话(`SessionHeader.isSeeded === true`)在侧边栏列表里没有 title,`@` 引用补全只显示会话 id,列表排序退化到创建时间。打开一次之后恢复。2026-09-19 的一台开发机上,241 条 projcache 记录里有 44 条 seeded,44 条的 `title` 行全部合法,列表一次都没读到。
+
+这不是缓存损坏,也不是版本失配。projcache 记录的 domain version 是 7,`identity.formatVersion` 是 4,两者分别是缓存自身的磁盘格式代际和 fold 所依据的 Session 日志格式代际,都是当前值。问题在读取路径。
+
+### 机制
+
+缓存记录(记录格式与前代恢复见 [投影缓存前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md))绑定一份 lifecycle identity:`formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`,`identityMatches` 做全等匹配。其中 `inheritedEventCount`(fork 继承的事件前缀长度,下称 cut)从 #3346 起不再出现在逻辑 header 里:header 只保留 `isSeeded` 这一位,精确 cut 跟随正文。Session 格式 v2 起(#3398),物理 header 行也不再存 `seedLength`,reader 从正文里 `session/end-seed {inherited: true}` 标记的 seq 推出 cut。
+
+于是 header-only 的读取拿不到 cut:JSONL 后端 `fromHeaderLine` 对 header-only 读取硬编码 `inheritedEventCount: 0`,`SessionPersistenceSnapshot` 只有 header、revision 和可选的 eventCount。三个只读 header 的缓存消费者都因此加了同一个守卫:
+
+| 消费者 | 守卫 | 兜底 | 后果 |
+|---|---|---|---|
+| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | 无 | 无 title,无 `sessionListMetadata`,blank 判定退到 `false`,排序退到 `createdAt` |
+| `packages/context/session-reference/src/index.ts` `projectedLabels` | 同 | 无 | `@` 补全按 id 显示,搜不到 title |
+| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | 同 | `observeSession` 读正文 | 结果正确,每个 seeded 子代一次正文读 |
+
+守卫存在时(#3346,2026-09-01),`list.ts` 还有 `probeSmallCold`:缓存 miss 且日志文件不超过 `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` 字节时读正文补全。它的目的是识别 blank 会话,只覆盖不到 1KB 的日志,对正常 fork 从未生效。#3400(2026-09-03)删掉它,列表回到纯元数据加缓存。#4320(2026-09-19)把 fork 变成一等功能(`packages/core/session/src/fork.ts`,任意 seq fork),每个 fork 都是 `isSeeded: true` 加精确 cut,seeded 会话数量从个位数变成几十条,重启后全部冷,问题集中显形。
+
+### 缓存的两种用途与校验位置
+
+`SessionProjectionCache` 的读写面和调用方:
+
+| 面 | 对记录做什么 | 写回 | 调用方 |
+|---|---|---|---|
+| `write(session)` | 三个强制点加节流写 checkpoint | 有 | 缓存内部监听 |
+| `hydratePrepared(session, events)` | 把 `rows` 当 fold 起点,从 `row.seq + 1` 继续 apply,结果进 live cell | 后续 checkpoint 写回 | `session-query/src/observation.ts` |
+| `coldSnapshot(meta, cut, events)` | fold 起点加回写 | 有 | 无生产调用方 |
+| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`:每行过 `ver` 和 `stateSchema`,`view` 后返回 | 无 | `list.ts`、`session-reference`、`list-children` |
+| `cachedPredecessorTitle(meta, cut)` | 同上,只看 `title`,允许更旧的 `formatVersion` | 无 | `list.ts` |
+
+身份校验全部在缓存内部(`recordFor` → `identityMatches`;`viewCheckpoint` 逐行校验 `ver` 与 schema)。`list.ts` 自己不校验,只是给不出 cut 时不调。
+
+spec.ts 与 README 描述身份检查的目的时用的都是同一个动词:防止 "seed state folded from an unrelated log"、"cannot seed the caller"。它防的是 fold 面。只读面从未做过 seed。
+
+### 客户端 store 无法保证建连数据覆盖提示
+
+客户端每个会话只有一个 `ProjectionValueStore`(`manager.projectionStores`;合并规则原记录于 [Session observation 与 projection 所有的客户端状态](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)),列表下发的 block、打开会话的 history 首页基线、control 基线、推送 frame、rename 结果全部写进同一个对象,`useProjection` 读的也是它。所有写入者平等,只有一条 higher-seq-wins 规则:`apply` 在 `seq <= row.seq` 时丢弃新值,`seed` 清理没带的键时只清 `row.seq <= cut` 的行。一个 seq 相等或偏高的列表提示会在会话打开后原样留在格子里。列表提示的 seq 来自磁盘记录,crash-repair 截断日志后它可以数字上高于建连 cursor,而那正是缓存错、建连对的情形。对提示做 seq 比较是用错了工具。
+
+## Decision
+
+### 缓存分成两个读面,cut 只在 fold 面比对
+
+| 面 | 身份 | 谁能提供 | 方法 |
+|---|---|---|---|
+| 只读面 | lifecycle 身份:`formatVersion + createdAt + cwd + isSeeded`,全部来自 header | 任何 header-only 调用方 | `cachedSnapshot(header, keys?)`、`cachedPredecessorTitle(header)` |
+| fold 面 | lifecycle 身份加 `inheritedEventCount` | 握有 Session 或正文的调用方 | `recordFor` 所服务的 `hydratePrepared`、`write`、`coldSnapshot` |
+
+只读面的两个方法不再接受 cut 参数。`inheritedEventCount` 继续写入记录,fold 面继续全等比对它;"不同 fork cut 的记录不能互相当种子"这条原始设计预期不变。
+
+只读面返回的 block 里,`asOfSeq` 是所服务各行中最低的水位,也就是存储记录自己的位置。header 既作证不了 cut,也作证不了这个水位与当前日志的可比性,所以可比性不由这个数字表达:Session list 给每条摘要的 `projections` block(`SessionProjectionHints`)加独立字段 `kind`,冷会话从 projcache 看出来的 block 标 `cached`,活会话由 Host live registry 算出的 block 标 `sequenced`。两个字段各管各的:`kind` 说明 `asOfSeq` 属于哪个序列空间,`asOfSeq` 是那个空间里的水位。
+
+### 只读面为什么可以不比 cut
+
+- 在同一个 `formatVersion` 下,一条日志的 cut 由 `(id, createdAt, cwd, isSeeded)` 唯一决定:fork 创建时写死,只有改变事件基数的格式迁移能改它,而那种迁移必然改 `formatVersion`。cut 是 lifecycle 的派生事实,不是独立坐标。
+- 只读面今天实际发生的是:unseeded 传常量 0(构造上恒真),seeded 直接不调。cut 在只读面上从未和任何真实事实比过。拿掉它,拒绝集合一个都不变:另一个 lifecycle 的记录(四字段任一不同)照样被拒;unseeded 结果相同;seeded 从不可寻址变成可寻址。
+- 只读面没有 seed、没有写回,输出是存储记录的纯函数,cut 不参与计算,也不流向任何地方。
+- 唯一多出来的暴露面:一份四字段全同、只有 cut 不同的记录。系统自身产生不出它,只有手工复制会话目录再改文件才行。就算出现,后果是列表显示它的值直到会话被打开,仍然不进任何 fold。
+
+### 消费者
+
+| 位置 | 改法 |
+|---|---|
+| `list.ts` `projectionsFor` | 删 `isSeeded` 分支和 `SessionLogOffset(0)`,冷会话统一 `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
+| `session-reference` `projectedLabels` | 同一症状,同一改法 |
+| `list-children.ts` `resolveColdIdentity` | 只跟随签名,`!header.isSeeded` 守卫和正文兜底原样保留;它需要 cut 做 inherited/owned 分类,属于 fold 语义 |
+
+### 客户端 store 区分 cached 与 sequenced 行
+
+`ProjectionValueStore` 的行分两类,按"这个值有没有本连接可比的 seq"命名:
+
+| 行 | 含义 | 携带 |
+|---|---|---|
+| `cached` | 从持久化 checkpoint 只读看出来的值,没有 Session 参与,seq 与本连接不可比 | 只有 `value` |
+| `sequenced` | 本连接内由 Host 对该 Session 算出的值,seq 在同一空间可比 | `value` 加 `seq` |
+
+规则:
+
+| 写入 | 遇到 `cached` 行 | 遇到 `sequenced` 行 |
+|---|---|---|
+| `applyCached(values)` | 覆盖 | 忽略 |
+| `seed(baseline)` | 整表丢弃 `cached` 行,再按 block 写入;block 没带的键按 `seq <= cut` 清理 | higher-seq-wins |
+| `apply(key, value, seq)` | 覆盖 | higher-seq-wins |
+
+seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `clear()`,再由列表重新写 cached、打开会话重新 seed。从 cached 到 sequenced 是无条件替换,不看 seq。
+
+写入口归类:
+
+| 入口 | 行类型 |
+|---|---|
+| `session.list` 响应里每条摘要的 `projections` block(`manager.refreshList`) | 按 block 的 `kind`:冷会话 `cached`,活会话 `sequenced` |
+| `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` |
+| history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced |
+| control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced |
+| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced |
+| 推送 frame(`manager` 处理 `projection` frame) | sequenced |
+| rename 成功后的 `title`(`session.ts`) | sequenced |
+
+`seed` 与 `apply` 签名不变。客户端按 `kind` 分流:`sequenced` block 逐键 `apply`,`cached` block 走 `applyCached`,不读它的 `asOfSeq`。
+
+### 不动的部分
+
+- Session 格式、物理 header 行、`fromHeaderLine`、persistence、`SessionPersistenceSnapshot`。
+- hydration 与 checkpoint 写路径,`recordFor` 的五字段全等。
+- `coldSnapshot` 保留,不因没有生产调用方而删除。
+- 老记录(domain v4/v5,缺 `isSeeded` 与 `inheritedEventCount`)对 seeded 会话继续 miss,等会话打开后被 v7 记录改写。
+- 列表下发全部带 wire 视图的行(`contextBreakdown`、`turnOutline` 等)这一点不在本次范围。
+
+### 最坏情形推演
+
+1. 列表用了一份错误记录,侧边栏显示错 title,格子里是 cached 行。
+2. 点进会话,首页基线到达前一瞬仍是错值。这是任何预填充都有的窗口。
+3. 首页基线 `seed`:所有 cached 行先被无条件丢弃,再按 block 写入。错值零残留,与 seq 大小无关。
+4. 之后列表刷新,cached 写入遇到 sequenced 行直接忽略,错值不会被刷回来。
+
+## Alternatives considered
+
+**把 cut 写回物理 header 行。** header-only 读取能直接拿到 cut,只读面无需改身份。代价是 Session 格式再升一版,V4 刚落地;并且要推翻 #3346"精确 cut 不伪装成 body-free metadata"的决定。否决:不为此改 header。
+
+**由 persistence 或 session-query 维护每个会话 cut 的索引。** 新增一套 durable 索引及其一致性维护,只为服务一个展示读取。否决。
+
+**恢复有界正文探测。** #3400 删掉的 `probeSmallCold` 思路,或由客户端对可见 seeded 会话异步 `refreshProjections`。违背列表零 I/O 原则,且历史阈值 1KB 说明它从未覆盖过正常 fork。否决。
+
+**只用 `asOfSeq: -1` 哨兵,不给 store 分层。** 服务端一处改动即可让提示在 seq 规则下永远落败。但它靠约定成立:一旦提示 seq 与基线 cut 相等或偏高(crash-repair 截断),`apply` 保留旧行,错值存活到下一帧。用户要求建连数据无条件覆盖提示,规则要写进 store 而不是靠 seq 约定模拟。
+
+**用 `asOfSeq: -1` 表达 cached,客户端按哨兵分流。** 只读面统一输出 `-1`,客户端把所有列表 block 当 cached。否决:一个字段同时承担两个含义,另一个含义只能靠约定推断;活会话的列表 block 带本连接可比的真实 seq,一律当 cached 就把它们也降级了,PR 评审复现了延迟到达、cut 更低的 control 基线覆盖更新列表值的回归。改为独立字段 `kind`,`asOfSeq` 保持各自来源的水位。
+
+**把 cut 从缓存身份里彻底删掉。** fold 面需要它:`restore` 从缓存行状态继续 apply,`schedule`、`subagentCatalog`、`permissions.seeded` 与 owned/inherited 归属都编码了 cut,错误会被写回并持久化。否决。
+
+**行类型命名 `hint` / `authoritative`。** 说的是可信度,而规则依据的是 seq 可比性。改为 `cached` / `sequenced`,名字直接说明规则用到的性质。
+
+## Consequences
+
+买到的:
+
+- 重启后 fork 会话在侧边栏立即有 title、`sessionListMetadata`(blank、lastPromptAt)和其余带 wire 的缓存值,排序按最后一次提问时间。
+- `@` 补全对 fork 按 title 显示和搜索。
+- 建连后的数据无条件替换列表提示,不再依赖 seq 大小。
+- 缓存身份的两级含义有了名字:lifecycle 身份回答"是不是同一份日志",fold 身份回答"能不能当续算起点"。
+
+付出的:
+
+- `cachedSnapshot` 与 `cachedPredecessorTitle` 签名变化,三个调用方同步修改。
+- `SessionProjectionHints` 新增必填字段 `kind`,所有产出列表摘要的地方和构造摘要的测试夹具都要带上。
+- 手工构造的同四字段、不同 cut 的记录会在列表上显示到会话被打开为止。
+- 基线没带的键连提示一起清掉:某个 Host 未挂载 `schedule` 时,打开会话后列表提示过的 schedule 标记消失。按"建连数据是真值"这是正确行为。
+- 老记录对 seeded 会话仍然 miss,直到打开重写。
+
+## Testing
+
+- `session-projection-cache/tests/cache.spec.ts`:seeded 冷 header 通过 `cachedSnapshot(header)` 取到全部版本匹配的行,`asOfSeq` 为行水位;unseeded header 对同一 id 的 seeded 记录被拒;`coldSnapshot` 对 cut 一致的记录从行续算、对 cut 不一致的记录整段重新 fold,对 unseeded 且 cut 非 0 的调用抛错;`cachedPredecessorTitle(header)` 对 seeded 的更旧 `formatVersion` 记录只给 `title`;不同 watermark 的多行合成一个 block,`asOfSeq` 取最低行。
+- `session-projection-cache/tests/fixtures.spec.ts`:归档的 v3 到 v6 记录继续只透出 predecessor title;无 lineage 字段的归档对 seeded 调用方继续 miss。
+- `api/session-controller/tests/session-cold.host.spec.ts`:seeded 冷会话摘要携带 `kind: 'cached'`、`title` 与 `sessionListMetadata`,`updatedAt` 取 `lastPromptAt`,缓存确实被查询,正文没有被读。
+- `api/session-controller/tests/session-projections.host.spec.ts`:用 `sessions.fork` 造一个真实 fork 并写入 projcache,销毁整个 Context 后在同一存储根上重启 Host,只凭 header 通过 `session.list` 读到 `kind: 'cached'` 的 `title` 与 `sessionListMetadata`,`inspect` / `open` 一次都没被调用。
+- `api/session-controller/tests/projection-store.client.spec.ts`:`applyCached` 只填空位、不覆盖 sequenced 行;任何 sequenced 写入(包括 cursor `-1` 的 frame 和 cut 更低的基线)替换 cached 行;基线先丢弃全部 cached 行再清理没带的键;面订阅在 cached 填充与丢弃时都收到通知。manager 路径:`cached` 列表 block 的水位再高,也被 cut 更低的 control 基线替换,之后的列表刷新拿不回来;`sequenced` 列表 block 按 higher-seq-wins,cut 更低的延迟基线既不覆盖也不清掉它,更高 seq 的 frame 仍能推进它。
+- `api/session-controller/tests/manager.client.spec.ts`:`api-session/added` 的 `cached` block 被同 cursor 的 control 基线替换;`cached` 列表 block 不覆盖已有的 sequenced title。
+- `api/session-controller/tests/inbox-projection.client.spec.ts`:不变,活会话的 `sequenced` 列表 block 仍压过延迟到达、cut 更低的 control 基线。
+- `context/session-reference/tests/session-reference.spec.ts`:seeded 冷会话按缓存 title 标注并可按 title 搜到,没有缓存记录的会话仍按 id 标注,两者都不读日志。
+- `subagent/subagent/tests/list-children.spec.ts`:不变,seeded 子代仍走正文观察。
diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
index c18976d463..56aaada1b7 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.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-08-17-durable-web-queue-recovery.md
-2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e
-2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3
+2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f
+2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c
diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
index c235cc47d5..5fbfdf0e62 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
@@ -18,7 +18,7 @@ The registry folds committed splices before `Session.append()` returns; each Age
The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
-A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values.
+A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all Host-sequenced values obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer history values or a live Session's list values; a cached list block viewed from the projection cache yields to the baseline instead ([listing identity and cached rows](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md)).
The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
index 2b224b5e1d..2aaffd6bc5 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
@@ -18,7 +18,7 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We
通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
-对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。
+对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有 Host 排序过的值都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 history 值或活 Session 的 list 值;从 projection cache 看出来的 cached list block 则让位于该 baseline([只读面按 lifecycle 身份匹配与 cached 行](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。
客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
deleted file mode 100644
index de5e509c9e..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Agent Note: Resume headers do not repeat system prompts
-
-Status: implemented
-
-English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md)
-
-## Problem
-
-Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation.
-
-## Decision
-
-The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition.
-
-A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible.
-
-Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction.
-
-## Alternatives considered
-
-**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain.
-
-**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison.
-
-**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory.
-
-## Consequences
-
-Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node.
-
-The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md
deleted file mode 100644
index 008e25d8d5..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Agent Note: Resume header 不重复系统提示词
-
-Status: implemented
-
-[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文
-
-## 问题
-
-fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。
-
-## 决策
-
-持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。
-
-部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。
-
-Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。
-
-## 考虑过的替代方案
-
-**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。
-
-**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。
-
-**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。
-
-## 后果
-
-继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。
-
-单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
deleted file mode 100644
index f3b428dbe0..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Agent Note: Settle pinned scroll deliveries before layout changes
-
-Status: implemented
-
-English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md)
-
-## Problem
-
-A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture.
-
-## Decision
-
-[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry.
-
-## Alternatives considered
-
-**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it.
-
-**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path.
-
-## Consequences
-
-Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
deleted file mode 100644
index 9b200f4553..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Agent Note: 在布局变化前处理贴底滚动事件
-
-Status: implemented
-
-[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文
-
-## Problem
-
-延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。
-
-## Decision
-
-[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。
-
-## Alternatives considered
-
-**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。
-
-**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。
-
-## Consequences
-
-贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
deleted file mode 100644
index c89c5328ee..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Agent Note: Keep the room reading independent of hidden split controls
-
-Status: implemented
-
-English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md)
-
-## Problem
-
-The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in.
-
-## Decision
-
-When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before.
-
-## Alternatives considered
-
-**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split.
-
-**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize.
-
-**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading.
-
-## Consequences
-
-The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md
deleted file mode 100644
index edf9948e54..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Agent Note: Keep the room reading independent of hidden split controls
-
-Status: implemented
-
-[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文
-
-## Problem
-
-dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。
-
-## Decision
-
-当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。
-
-## Alternatives considered
-
-**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。
-
-**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。
-
-**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。
-
-## Consequences
-
-空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md
deleted file mode 100644
index c5d778f8fb..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Agent Note: Composer placeholder emptiness
-
-Status: implemented
-
-English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md)
-
-## Problem
-
-Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces.
-
-## Decision
-
-The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression.
-
-## Alternatives considered
-
-**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states.
-
-## Consequences
-
-All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md
deleted file mode 100644
index b5f82bcd86..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Agent Note: Composer 占位提示的判空规则
-
-Status: implemented
-
-[English](2026-09-09-composer-placeholder-whitespace.md) | 中文
-
-## Problem
-
-占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。
-
-## Decision
-
-原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。
-
-## Alternatives considered
-
-**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。
-
-## Consequences
-
-所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md
deleted file mode 100644
index 01d70e646e..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Agent Note: Transcript width handles stay behind chat content
-
-Status: implemented
-
-English | [中文](2026-09-14-transcript-width-handle-layering.zh.md)
-
-## Problem
-
-The Conversation shell renders each content-width handle as a full-height absolute strip beside the reading column. A high stacking level placed that strip above wide Markdown tables that legitimately overflow the column. The handle glow covered their content and its pointer target could replace their interaction target.
-
-## Decision
-
-The Conversation shell keeps width handles at level zero without creating a body-wide stacking context. Chat raises concrete table elements to level one, while the reading column, column-bounded tool cards, and table breakout wrapper create no new stacking level. A table therefore owns the gutter only where its painted box actually reaches it; a fitting four-column table leaves the adjacent gutter draggable. Fixed message tooltips remain outside the table stacking context and can still paint over the sticky composer.
-
-This rule relies on the supported Chromium engine not turning ChatView's inline-size query container into an intermediate stacking context. The same engine behavior already lets the back-to-bottom control outrank the sticky composer. The Chromium hit-test scenario pins that assumption; raising the whole Chat root would instead make its transparent full-width box reclaim the gutter.
-
-A width handle starts resizing only for the primary pointer button. Ordinary wheel input over the handle is normalized from pixel, computed line-height, or page units and forwarded to the direct sibling transcript scrollport, so hovering the gutter does not suspend reading. Ctrl+wheel is not forwarded because it represents browser zoom or a trackpad pinch gesture.
-
-Each side's hit strip is at most 10px wide. Its hover indicator uses the lower-contrast resting scrollbar tint and is a 2px line with a 16px solid center and 28px fades, for a 72px total visible length.
-
-Composer chrome retains its higher layer, so its full sticky footer band intentionally does not start a width resize. A handle whose pointer capture began above that band temporarily rises to level eight, keeping the indicator visible until release. A full-view composer takeover continues to hide the handles entirely.
-
-## Alternatives considered
-
-**Move the handles farther from the reading column.** Wide tables can use the available transcript width, so a fixed larger inset would only reduce the overlap for some window sizes and would make the handles harder to reach.
-
-**Disable the handles whenever a transcript contains wide content.** One wide row would remove resizing from the entire Session, including empty gutter beside unrelated rows.
-
-**Inspect the elements under the pointer in JavaScript.** Dynamic hit testing would duplicate browser stacking and pointer dispatch rules and could still disagree with new plugin renderers.
-
-## Consequences
-
-Visible Chat content owns pointer input only where its painted element extends into a width-handle strip, while bare gutter keeps a quieter resize affordance, primary-button drag, and transcript wheel scrolling. Unit tests pin the declared handle geometry, pointer-button behavior, direct scrollport lookup, zoom exclusion, and every DOM delta mode. Chromium `elementFromPoint` coverage proves an overflowing table wins the hit while a fitting `md-table-wide` wrapper does not, and a message-action test proves fixed tooltips still paint above the sticky composer.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md
deleted file mode 100644
index c6e03ec577..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Agent Note: 正文宽度拖拽条位于 Chat 内容下方
-
-Status: implemented
-
-[English](2026-09-14-transcript-width-handle-layering.md) | 中文
-
-## 问题
-
-Conversation shell 将每个正文宽度拖拽条渲染为阅读列旁的全高绝对定位区域。较高的堆叠层级使该区域盖在会合理溢出阅读列的宽 Markdown 表格之上。拖拽条的高亮会遮住表格内容,其指针目标也可能取代表格自身的交互目标。
-
-## 决策
-
-Conversation shell 将宽度拖拽条保持在第零层,不创建覆盖整个 body 的堆叠上下文。Chat 只将具体的表格元素提到第一层,阅读列、限定在列内的工具卡片与表格 breakout 包装层都不创建新的堆叠层级。因此只有表格的可见盒子实际延伸进沟槽时才会占有指针;能容纳在正文中的四列表格不会阻断旁边的沟槽拖拽。消息的固定定位 Tooltip 保持在表格堆叠上下文之外,仍可绘制在粘滞 composer 上方。
-
-该规则依赖交付的 Chromium 引擎不会把 ChatView 的 inline-size query container 变成中间堆叠上下文。back-to-bottom 控件能高于粘滞 composer 也已依赖相同的引擎行为。Chromium 命中测试固定这一假设;如果改为提高整个 Chat root,其透明全宽盒子反而会重新占用沟槽。
-
-宽度拖拽条仅使用主指针按键开始调整。拖拽条上的普通滚轮输入会从像素、计算后的行高或页面单位规整为像素,再转发给直接相邻的 transcript scrollport,因此指针悬停在沟槽时不会中断阅读滚动。Ctrl+滚轮表示浏览器缩放或触控板捏合手势,不会被转发。
-
-每侧的命中区域最宽为 10px。悬停指示线使用对比度更低的静止滚动条颜色,线宽 2px,中心实色段长 16px,两端各渐隐 28px,可见总长度为 72px。
-
-Composer chrome 保留更高层级,因此完整的粘滞底部区带刻意不会开始宽度调整。如果拖拽条在进入该区带之前已获得指针捕获,它会临时提到第八层,使指示线在松开前保持可见。占据完整 View 的 composer takeover 仍会完全隐藏拖拽条。
-
-## 考虑过的替代方案
-
-**将拖拽条移到离阅读列更远的位置。** 宽表格可以使用 transcript 的可用宽度,因此固定增加间距只能减少部分窗口尺寸下的重叠,还会让拖拽条更难触达。
-
-**只要 transcript 包含宽内容就禁用拖拽条。** 一行宽内容会让整个 Session 都无法调整宽度,包括与其他行相邻的空白沟槽。
-
-**在 JavaScript 中检查指针下方的元素。** 动态命中测试会重复浏览器的堆叠与指针分发规则,并且仍可能与新的插件 renderer 不一致。
-
-## 后果
-
-可见 Chat 内容只在其可见元素实际延伸进宽度拖拽区域时拥有指针输入,裸露沟槽则保留更轻的宽度调整提示、主按键拖拽和 transcript 滚轮滚动。单元测试固定声明的拖拽条尺寸、指针按键行为、直接 scrollport 查找、缩放排除和每种 DOM delta mode。Chromium `elementFromPoint` 覆盖证明溢出表格获得指针命中,而可容纳的 `md-table-wide` 包装层不会;消息操作测试证明固定定位 Tooltip 仍绘制在粘滞 composer 上方。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
deleted file mode 100644
index 7a5f3eea56..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Agent Note: Clean application-owned packages before Desktop production boot
-
-Status: implemented
-
-English | [中文](2026-09-15-desktop-profile-core-cleanup.zh.md)
-
-## Problem
-
-Old Desktop profiles contain installed core packages and local tarball dependency declarations. Local package precedence can combine an old Web frontend with new plugins even when the application carries a consistent release. Development fallback links also remain when users switch to an installed application.
-
-## Decision
-
-Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
-
-The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
-
-## Alternatives considered
-
-**Installer-only cleanup** misses other user profiles and packages recreated after installation; both platforms use startup cleanup.
-
-**Deleting every organization-prefixed package** can remove optional official plugins. Explicit current and historical package inventories determine ownership.
-
-**Deleting package directories alone** lets pnpm reinstall the same old packages from retained declarations and overrides.
-
-## Consequences
-
-Production loses profile-local overrides of application-owned packages. A changed profile loses its lockfile and the next pnpm operation resolves remaining plugin dependencies again. Cleanup does not run pnpm or create rollback state; failures stop preparation and can be retried. Redirected package-parent directories fail before deletion. Focused tests cover retained plugins, declarations, repeated cleanup, development exclusion, retired packages, invalid records, and link targets.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md
deleted file mode 100644
index 4a9cad94a8..0000000000
--- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Agent Note: 在 Desktop 生产启动前清理应用管理的包
-
-Status: implemented
-
-[English](2026-09-15-desktop-profile-core-cleanup.md) | 中文
-
-## Problem
-
-旧 Desktop profile 包含已安装的核心包及本地 tarball 依赖声明。即使应用携带一致的发布产物,本地包优先规则仍可能把旧 Web 前端与新插件组合起来。用户切换到已安装应用时,开发模式的回退链接也会残留。
-
-## Decision
-
-生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
-
-实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
-
-## Alternatives considered
-
-**仅在安装器中清理**会遗漏其他用户的 profile 和安装后重新产生的包;两个平台都在启动时清理。
-
-**删除组织名前缀下的所有包**可能误删可选官方插件。明确的当前及历史包清单决定归属。
-
-**仅删除包目录**会让 pnpm 根据保留的声明和 overrides 重新安装相同旧包。
-
-## Consequences
-
-生产版不再使用 profile 对应用管理包的本地覆盖。发生变化的 profile 丢弃锁文件,下次 pnpm 操作会重新解析其余插件依赖。清理不运行 pnpm,也不创建回滚状态;失败会停止准备流程,之后可以重试。重定向的包父目录会在删除前报错。定向测试覆盖插件保留、声明、重复清理、开发模式排除、退役包、无效记录及链接目标。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml
new file mode 100644
index 0000000000..cc6eb7ff26
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.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-15-trajectory-attachment-presentation.md
+2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb
+2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md
new file mode 100644
index 0000000000..03d737d67e
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md
@@ -0,0 +1,55 @@
+# Agent Note: Consistent Trajectory attachment presentation
+
+Status: implemented
+
+English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md)
+
+## Problem
+
+A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356).
+
+## Decision
+
+Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields.
+
+| Location | Behavior |
+|---|---|
+| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. |
+| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. |
+| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. |
+| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. |
+| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. |
+
+Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area.
+
+### Ownership
+
+[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON.
+
+[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior.
+
+This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens.
+
+The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback.
+
+## Alternatives considered
+
+**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments.
+
+**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths.
+
+**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation.
+
+## Consequences
+
+The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry.
+
+Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements.
+
+## Verification
+
+[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer.
+
+The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory.
+
+Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md
new file mode 100644
index 0000000000..55c63bf3d9
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md
@@ -0,0 +1,55 @@
+# Agent Note: 统一 Trajectory 附件展示
+
+Status: implemented
+
+[English](2026-09-15-trajectory-attachment-presentation.md) | 中文
+
+## 问题
+
+用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。
+
+## 决定
+
+Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。
+
+| 位置 | 行为 |
+|---|---|
+| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 |
+| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 |
+| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 |
+| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 |
+| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 |
+
+已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。
+
+### 职责
+
+[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。
+
+[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。
+
+本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。
+
+[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。
+
+## 考虑过的替代方案
+
+**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。
+
+**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。
+
+**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。
+
+## 影响
+
+共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。
+
+紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。
+
+## 验证
+
+[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。
+
+已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。
+
+布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml
new file mode 100644
index 0000000000..10665d6e64
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.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-16-movable-mandatory-update-window.md
+2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
+2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md
new file mode 100644
index 0000000000..4695074429
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md
@@ -0,0 +1,23 @@
+# Agent Note: Movable mandatory-update window
+
+Status: implemented
+
+English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
+
+## Problem
+
+The Windows mandatory-update page used a frameless modal overlay sized to the product window. The modal disabled its parent, so the parent's native title bar could not be used to move or maximize either window. Closing the overlay was intercepted, leaving no visible exit control.
+
+## Decision
+
+On Windows, mandatory policy uses a separate native framed modal with move, resize, and maximize controls. The parent remains disabled while policy blocks interaction. Closing the modal requests normal application shutdown; it never dismisses policy and resumes the product window. Other platforms retain the existing overlay presentation. The [mandatory-update decision](../feature/2026-09-11-desktop-mandatory-update-client.md) still owns policy and installation authorization.
+
+## Alternatives considered
+
+**Keep the full-content overlay and add a drag region.** A drag region would move the disabled parent indirectly and would not restore native maximize or close controls.
+
+**Let close dismiss the policy page.** That would expose the blocked product window without a fresh no-force policy response.
+
+## Consequences
+
+Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. Installer-owned quit disposes the modal before Electron closes windows, so its close guard cannot block installation. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md
new file mode 100644
index 0000000000..3b416c53dc
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md
@@ -0,0 +1,23 @@
+# Agent Note: 可移动的强更窗口
+
+Status: implemented
+
+[English](2026-09-16-movable-mandatory-update-window.md) | 中文
+
+## Problem
+
+Windows 强更页面原先使用与产品窗口等大的无边框模态覆盖层。模态窗口禁用了父窗口,因此无法使用父窗口的原生标题栏移动或最大化窗口。覆盖层的关闭操作又被拦截,界面上没有可用的退出控件。
+
+## Decision
+
+在 Windows 上,强更策略使用独立的原生有边框模态窗口,支持移动、调整大小和最大化。策略阻塞期间父窗口仍不可操作。关闭模态窗口会请求应用正常退出,不会取消策略并恢复产品窗口。其他平台保留现有覆盖层展示。[强更决策](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)仍负责策略与安装授权。
+
+## Alternatives considered
+
+**保留覆盖整个内容区域的窗口并添加拖动区域。** 拖动区域只能间接移动被禁用的父窗口,也无法恢复原生最大化和关闭控件。
+
+**允许关闭时直接取消策略页面。** 这样会在未取得新的无需强更响应时暴露被阻塞的产品窗口。
+
+## Consequences
+
+Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。安装器接管退出时,壳会在 Electron 关闭窗口前释放模态窗口,避免关闭拦截阻止安装。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml
new file mode 100644
index 0000000000..f8d7bd6adf
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.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-16-present-delayed-windows-installer.md
+2026-09-16-present-delayed-windows-installer.md: d4ffcfdc1183bbc414cfdd82595053c959a9975c
+2026-09-16-present-delayed-windows-installer.zh.md: bf97ed8a4f6c7184d1e048c21f7975c90fb4a9cb
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md
new file mode 100644
index 0000000000..d4ffcfdc11
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md
@@ -0,0 +1,23 @@
+# Agent Note: Present delayed Windows installer
+
+Status: implemented
+
+English | [中文](2026-09-16-present-delayed-windows-installer.zh.md)
+
+## Problem
+
+The native installer hides its window while preparing resources. If the user activates another application during that interval, the welcome page can appear behind that application and seem absent.
+
+## Decision
+
+The first welcome-page display moves the installer above ordinary windows without activating it. When another window owns the foreground, the installer also flashes its taskbar button until foreground interaction. Later page changes do not repeat the move. The window never becomes permanently topmost.
+
+## Alternatives considered
+
+**Force foreground focus.** Windows may reject a background process's foreground request, and taking focus after the user switches applications interrupts their current action.
+
+**Keep the installer topmost.** A persistent topmost window obscures applications the user intentionally opens during installation.
+
+## Consequences
+
+The welcome page becomes visible when preparation completes, while the user retains control of focus and can cover the installer again. A native window-order test and the signed installer smoke test exercise the first display.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md
new file mode 100644
index 0000000000..bf97ed8a4f
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md
@@ -0,0 +1,23 @@
+# Agent Note: 延迟显示的 Windows 安装窗口
+
+Status: implemented
+
+[English](2026-09-16-present-delayed-windows-installer.md) | 中文
+
+## Problem
+
+原生安装程序准备资源时会隐藏窗口。如果用户在此期间激活其他应用,欢迎页可能出现在该应用后面,看起来像没有启动。
+
+## Decision
+
+欢迎页首次显示时,安装窗口会移到普通窗口前方,但不抢占焦点。如果其他窗口位于前台,安装程序还会闪烁任务栏按钮,直至用户进行前台交互。后续页面切换不会重复置前。安装窗口不会永久置顶。
+
+## Alternatives considered
+
+**强制获取前台焦点。** Windows 可能拒绝后台进程的前台请求,而且用户切换应用后再抢回焦点会打断当前操作。
+
+**让安装窗口始终置顶。** 持续置顶会遮挡用户在安装期间主动打开的其他应用。
+
+## Consequences
+
+准备完成时欢迎页可见,用户仍能控制焦点,也可以再次用其他窗口覆盖安装程序。原生窗口顺序测试和签名安装程序冒烟测试覆盖首次显示。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml
index f0a89cb78d..6377a233cb 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.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-17-thinking-markdown.md
-2026-09-17-thinking-markdown.md: de49cb42ba537700f6584c9f4cef9a57fb2e3605
-2026-09-17-thinking-markdown.zh.md: e403630138f2f39aec0834e550d4d4a8860a67fe
+2026-09-17-thinking-markdown.md: 6990fc6277d7d1c0a088a50cfb88694b2d9c55c7
+2026-09-17-thinking-markdown.zh.md: 963626088e50a44bcf76c7fc7ec3653e93fcd2c9
diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md
index de49cb42ba..6990fc6277 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md
@@ -10,9 +10,9 @@ Chat Thinking contains model-authored Markdown, but literal rendering exposes he
## Decision
-[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../feature/2026-09-04-web-clickable-link-styles.md); code retains its monospace font and background.
+[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../../../../docs/web-styling.md); code retains its monospace font and background.
-Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band.
+Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) above the scrolling content without a second sticky band.
Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory pins Thinking to its inspector’s fixed 13px/20px tier, independent of Chat’s content-size setting. Its answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md).
diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md
index e403630138..963626088e 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md
@@ -10,9 +10,9 @@ Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标
## 决策
-[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../feature/2026-09-04-web-clickable-link-styles.zh.md)的次级内容例外;代码保留等宽字体和底色。
+[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../../../../docs/web-styling.zh.md)的次级内容例外;代码保留等宽字体和底色。
-表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。
+表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) 位于滚动内容上方,无需第二条 sticky 栏。
Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml
similarity index 54%
rename from .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml
rename to .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml
index 5df3dbdf86..0898823b35 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml
@@ -1,6 +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-07-pinned-scroll-delivery-before-layout.md
-2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45
-2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md
+2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54
+2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263
diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md
new file mode 100644
index 0000000000..1775276dee
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md
@@ -0,0 +1,31 @@
+# Agent Note: Replay assistant blocks in Messages user input
+
+Status: implemented
+
+English | [中文](2026-09-18-messages-input-history-compatibility.zh.md)
+
+## Problem
+
+Saved subagent settlement notices can contain child reasoning and tool calls in parent user messages. Chat Completions and pi-ai omit these blocks from user input, while rejecting them in Messages prevents every later request containing the same history. The [text-only settlement producer](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md) prevents new notices from carrying these blocks but cannot repair already recorded messages.
+
+## Decision
+
+The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
+
+This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content.
+
+## Alternatives considered
+
+**Fix only the producer.** New notices are representable, but saved notices still block protocol switching and continuation.
+
+**Rewrite Session history or flatten reasoning into user text.** Rewriting removes original evidence; flattening sends child reasoning as parent input. Request-only omission preserves the durable record and existing input semantics.
+
+**Ignore every unsupported block.** Unknown plugin content has no Messages representation defined here. Compatibility for two known assistant block types does not establish one.
+
+**Move a system update to an earlier retained user turn.** When the corresponding user input is omitted, moving the update before an earlier assistant changes its position in the conversation and rewrites a previously sent prefix. The [documented in-history limitation](../../../../packages/llm/llm-deepseek/README.md#known-limitations-and-deferred-work) remains explicit rather than inventing a user message or relocating the update.
+
+## Consequences
+
+Saved notices with retained text can continue through Messages. The provider no longer diagnoses these two block types as user-input producer errors; the settlement producer remains responsible for text-only notices. This does not promise lossless provider input or compatibility for every historical message sequence.
+
+Verification covers user and tool-result filtering, unchanged durable content, HTTP continuation, and a [headless Session replay](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml). Serialization tests pin rejection of an in-history update when its user input is entirely omitted, both before another assistant and at request end, and acceptance when text or a tool result retains the corresponding turn.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md
new file mode 100644
index 0000000000..50367e33c2
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 回放 Messages 用户输入中的助手块
+
+Status: implemented
+
+[English](2026-09-18-messages-input-history-compatibility.md) | 中文
+
+## 问题
+
+已保存的子 Agent 结算通知可能在父级用户消息中包含子级推理和工具调用。Chat Completions 与 pi-ai 会在用户输入中省略这些块,而 Messages 拒绝它们会阻断包含相同历史的所有后续请求。[仅生成文本的结算通知生产方](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)可以避免新通知携带这些块,但无法修复已经记录的消息。
+
+## 决策
+
+[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
+
+这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。
+
+## 考虑过的替代方案
+
+**只修复生产方。** 新通知可以表示,但已保存的通知仍会阻断协议切换和会话续接。
+
+**改写 Session 历史或把推理压平为用户文本。** 改写会移除原始证据;压平会把子级推理作为父级输入发送。只在请求中省略可以保留持久化记录和现有输入语义。
+
+**忽略所有不支持的块。** 此处没有定义未知插件内容的 Messages 表示方式。兼容两种已知助手块类型并不意味着其他类型也有对应表示。
+
+**把 system 更新移到更早保留的用户轮次。** 对应用户输入被省略时,将更新移到更早的 assistant 之前,会改变它在对话中的位置,并改写已发送的前缀。保留明确记录的[历史内更新限制](../../../../packages/llm/llm-deepseek/README.zh.md#known-limitations-and-deferred-work),不编造用户消息或移动更新位置。
+
+## 后果
+
+保留文本的已保存通知可以通过 Messages 继续会话。提供方不再将这两种块类型诊断为用户输入生产方错误;结算通知生产方仍负责仅生成文本通知。这不保证提供方输入无损,也不保证兼容所有历史消息序列。
+
+验证覆盖用户与工具结果过滤、持久化内容不变、HTTP 续接,以及 [headless Session 回放](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml)。序列化测试固定了历史内更新在对应用户输入被完全省略时的拒绝行为,覆盖下一个 assistant 之前和请求结束两种位置;文本或工具结果保留对应轮次时则可以通过。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.i18n.yaml
new file mode 100644
index 0000000000..a49e4b2351
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.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-19-declared-session-attachment-carriers.md
+2026-09-19-declared-session-attachment-carriers.md: 1619624800dc5e1ee8d6a4f4b64b84a6c80078c2
+2026-09-19-declared-session-attachment-carriers.zh.md: bd66b1c80469e2adbd7becc1b015d390978becc6
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md
new file mode 100644
index 0000000000..1619624800
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md
@@ -0,0 +1,31 @@
+# Agent Note: Declared Session attachment carriers
+
+Status: implemented
+
+English | [中文](2026-09-19-declared-session-attachment-carriers.zh.md)
+
+## Problem
+
+Session attachment authorization and ZIP export inferred attachment ownership from common payload field names. An unknown ignorable event could therefore authorize an image read or export file bytes. Compaction content under `summary` and `rawOutput` was outside that field-name scan.
+
+## Decision
+
+Both consumers select content fields by built-in event type. They inspect completed assistant blocks and direct content blocks, including flat V4 tool-role messages. Other content-block fields and unknown event payloads remain opaque, even when they contain objects resembling image or file references.
+
+The controller applies this rule before reading image bytes. The exporter applies it before reading image or file bytes and retains the complete logical log. Historical logs reach these readers after persistence converts them to current logical events. Title requests are excluded because V4 requires a single text block. Compaction uses its declared content fields: the basic producer currently emits text, while other valid compaction producers may supply attachments there.
+
+## Alternatives considered
+
+**Scan common keys on every event.** This lets a field name grant attachment access without a defined content meaning. It also misses valid content stored under other declared fields.
+
+**Interpret historical wrappers in attachment readers.** Historical conversion belongs to the adjacent format packages. Current attachment readers use flat V4 content and do not maintain a second interpretation of old blocks.
+
+## Consequences
+
+Plugins that store attachments only in custom events cannot use these built-in readers for those references. A plugin needs a supported content occurrence or its own reader. Adding a built-in content carrier requires updating both readers and their acceptance and refusal tests. Historical codec inventories remain version-owned; changing this policy must not change what a released decoder recognizes.
+
+The change affects attachment lookup and archive membership, not Session encoding or the model-visible message history.
+
+## Verification
+
+Controller and archive tests cover declared carriers, flat tool results, opaque plugin payloads, and misleading extra fields. Refusal tests assert that attachment storage is never read; archive tests also compare the exported log text.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md
new file mode 100644
index 0000000000..bd66b1c804
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: Session 声明的附件载体
+
+Status: implemented
+
+[English](2026-09-19-declared-session-attachment-carriers.md) | 中文
+
+## 问题
+
+Session 附件授权与 ZIP 导出按常见载荷字段名推断附件归属。因此,未知可忽略事件也能授权图片读取或导出文件字节。压缩事件的 `summary` 与 `rawOutput` 内容不在这套字段名扫描范围内。
+
+## 决策
+
+两个消费者均按内置事件类型选择内容字段。它们检查已完成的 assistant 块与直接内容块,包括扁平的 V4 tool 角色消息。其他内容块字段与未知事件载荷保持不透明,即使其中含有类似图片或文件引用的对象。
+
+控制器在读取图片字节前应用此规则。导出器在读取图片或文件字节前应用此规则,并保留完整逻辑日志。历史日志由持久化层转换成当前逻辑事件后交给这些读取器。标题请求不在附件载体之列,因为 V4 要求它只含一个文本块。压缩事件使用其声明的内容字段:基础生产者目前输出文本,其他合法压缩生产者可以在这些位置提供附件。
+
+## 考虑过的替代方案
+
+**扫描所有事件的常见键。** 这使字段名在没有明确内容语义时授予附件访问权,也会遗漏其他已声明字段中的有效内容。
+
+**在附件读取器中解释历史包装块。** 历史转换由相邻格式包负责。当前附件读取器使用扁平的 V4 内容,不维护第二套旧块解释。
+
+## 影响
+
+仅在自定义事件中存储附件的插件不能通过这些内置读取器读取相关引用。插件需要使用受支持的内容位置,或提供自己的读取器。新增内置内容载体时,需要同步更新两个读取器及其接纳和拒绝测试。历史编解码器的清单仍归各版本所有;调整此策略不能改变已发布解码器识别的内容。
+
+此变更影响附件查找与归档成员,不影响 Session 编码或模型可见消息历史。
+
+## 验证
+
+控制器和归档测试覆盖声明的载体、扁平工具结果、不透明插件载荷及误导性额外字段。拒绝测试断言不会读取附件存储;归档测试还比较导出的日志文本。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml
new file mode 100644
index 0000000000..4ede5bd457
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.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-19-v3-incomplete-child-catalog-evidence.md
+2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92
+2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
new file mode 100644
index 0000000000..22ca915024
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
@@ -0,0 +1,29 @@
+# Agent Note: Preserve V3 Sessions with incomplete child catalog evidence
+
+Status: implemented
+
+English | [中文](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)
+
+## Problem
+
+A valid V3 child log can have no own descriptor, an unsupported descriptor version, or multiple descriptors. Its header still establishes its identity and parent relationship. Requiring one supported descriptor to migrate the parent makes unavailable discovery fields prevent access to otherwise readable history.
+
+## Decision
+
+The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
+
+Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures.
+
+## Alternatives considered
+
+**Refuse the whole parent.** Discovery metadata is insufficient to justify making valid parent history unavailable.
+
+**Choose the first or last descriptor.** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) takes the first under the establishing provider’s exactly-once rule; the [identity projection](../../../../packages/subagent/subagent/src/projection.ts) takes the last to override inherited identities. Multiple own records violate that rule. Backfill requires one own record rather than choosing between these consumer policies.
+
+**Infer missing fields.** Invented mode or label fields would become durable catalog facts without evidence.
+
+## Consequences
+
+A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
+
+Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md
new file mode 100644
index 0000000000..c11e6de2c2
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 保留子目录证据不完整的 V3 Session
+
+Status: implemented
+
+[English](2026-09-19-v3-incomplete-child-catalog-evidence.md) | 中文
+
+## Problem
+
+有效 V3 子日志可能没有自身 descriptor、descriptor 版本不受支持,或存在多个 descriptor。其 header 仍能确定身份及父子关系。要求一个受支持的 descriptor 才迁移父日志,会让不可获得的发现字段阻止读取原本可读的历史。
+
+## Decision
+
+V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
+
+已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
+
+## Alternatives considered
+
+**拒绝整个父 Session。** 发现元数据不足以成为禁止读取有效父历史的理由。
+
+**选择首个或末个 descriptor。** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) 依据建立提供方恰好写入一次的规则取首个;[身份投影](../../../../packages/subagent/subagent/src/projection.ts)取末个以覆盖继承身份。多个自身记录违反该规则。补填要求一个自身记录,而不在这两种消费者策略之间做选择。
+
+**推断缺失字段。** 推断的 mode 或 label 会在没有证据的情况下成为持久目录事实。
+
+## Consequences
+
+没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
+
+单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
index cd51630220..21f75ab4b4 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7
-2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc
+2026-07-27-web-subagent-conversations.md: 3798a5a2e5deb8b3801298def31c3a1890877796
+2026-07-27-web-subagent-conversations.zh.md: 4531528dd2a58baff3aaeaf53f20e037fce6cec4
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
index d0713731de..3798a5a2e5 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
@@ -10,12 +10,14 @@ Session-backed subagents have durable identities, persisted transcripts, and a d
The browser must preserve the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md): a continuable child has at most one process-local Activation, accepts later work only through the exact live direct parent, and uses the Agent inbox as its sole FIFO. Viewing history must not create an Activation. Once an inbox message is accepted, the HTTP caller neither owns its execution nor gains a cancellation handle.
-The UI must also preserve the membership, modes, and diagnostics of the [durable catalog](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md). The shared service reports live-preferred corpus activity, while the Web projection replaces it with the exact child Agent driver's `running` or `inactive` state. Neither activity is a durable outcome or a promise that continuation will succeed.
+The UI must also preserve the membership and modes of the [durable catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.md). The Web projection adds the exact child Agent driver's `running` or `inactive` state. Neither activity is a durable outcome or a promise that continuation will succeed.
## Decision
The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation.
+The same header row can open a child in the right Sidebar as `dsh-resource://subagentchat/session/?parent=&mode=`. The open prefers a separate pane and falls back to the current pane when no split is available. The Sidebar tab renders the shared Conversation Component Factory with its width controls omitted, so main and embedded conversations share one assembly without sharing layout chrome.
+
Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation.
The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
@@ -30,16 +32,16 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
| Design intent | Shipped contract |
| --- | --- |
-| The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. |
+| The session header opens a compact child list. | The trigger counts healthy direct entries returned by the parent catalog service. |
| Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. |
-| Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. |
+| Nested agents expand progressively. | A row remains expandable until its own catalog has loaded empty; expansion loads only that direct catalog and retains the row's parent address. |
| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
## Product contract
-The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
+The child-count control appears when the direct catalog contains entries or its read fails; it stays hidden while an empty catalog is absent or loading, and after a successful empty read. On an ordinary session, a slash separates the current title from that control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the optional session-summary title, and a missing switcher catalog loads on interaction. Only the current subagent appends its own direct-child count control. Long titles truncate before the fixed chevron. The trigger reports the direct catalog's total and running counts. Ordinary sidebar rows hide subagent-origin Sessions and read their direct running-child count from the same loaded catalog map; Session summaries supply activity but never create membership. Running direct children use the shared tertiary-grey ongoing loader. Pending interaction outranks parent running; either remains primary while direct-child activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, direct-child activity outranks an unviewed completion reminder, which returns after the last running direct child stops. A tree row remains expandable while its child catalog is absent, loading, or failed, and becomes a known leaf only after that catalog loads empty. Loading shows a generic notice rather than summary-derived placeholder rows. The tree presents continuable and one-shot rows, falling back to the Session id when an optional one-shot label is absent.
-`running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
+`running` means the child Agent driver is draining work; `inactive` means that driver is idle or absent. Session-list baselines and status events supply activity, and removal marks completed subagents inactive while retaining their display projections. The shared parent `subagentCatalog` projection supplies membership. Initial reads and pushed values meet in one projection store; a newer sequence wins. [The projection-consumer decision](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns loading, reconnect, and synchronization tradeoffs. A prompt response remains delivery-time authority.
Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome.
@@ -51,17 +53,17 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part
## Host adapter and wire contract
-`@deepseek-ai/dsh-subagent` owns the browser-safe generated `subagent` Remote namespace:
+The Session Controller owns catalog and history reads; `@deepseek-ai/dsh-subagent` owns continuation controls:
-- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`.
-- `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent.
+- `session.projections` takes `sessionId` and returns one live-preferred Session observation’s complete projection baseline without activating an Agent. The Client seeds the standard projection store; subagent consumers select `subagentCatalog`. Parent Agent availability comes from Session-list summaries and lifecycle events.
+- `session.page` and `session.follow` take the full mode-bearing address. They validate the child header, direct parent, descriptor identity, and mode at the observed cut, then return ordinary raw events, pagination, live reconciliation, and Host projection baselines without publishing an Agent.
- `subagent.prompt` accepts only a `mode: 'continuable'` address, `delivery: 'queue' | 'steer'`, and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, uses the continuation manager's shared human-delivery admission, and returns the accepted `MessageId`.
-The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
+The gateway maps missing parents or catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
-Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
+Viewing persisted history creates no Agent by itself. When a follow-up materializes a cold child Activation, the existing Host and Session journal streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `session.follow`.
-The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
+The ordinary `session.page` and `session.follow` address is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
@@ -71,7 +73,7 @@ The React-free runtime owns catalogs, single-flight refreshes, retained addresse
Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, focus, and hover timers. `ui-conversation` declares a lineage slot for the current ordinary title and every subagent breadcrumb, passes plain breadcrumb identity and display text plus an upward-navigation callback for ancestors, and retains the ordinary title as the render fallback. `@deepseek-ai/dsh-client-ui-subagent` occupies each lineage slot with direct-parent catalog navigation and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`.
-Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. The same existing `host/session-added` frame marks a loaded direct parent row expandable without introducing a catalog event stream. Descriptor mode and catalog verification remain the authority for navigation, continuation, and authorization.
+Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. Parent catalog projection owns membership and tree structure; descriptor identity and exact-parent checks own addressed history and continuation validation.
The package's existing `@label` source remains separate plain-text model input. It does not resolve labels to addresses or acquire continuation semantics.
@@ -95,23 +97,23 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
**Infer mode or sidebar filtering from lineage.** Rejected because ordinary forks share `parentSession`. The descriptor-backed catalog owns mode; the separate `origin` marker is only a cheap navigation classifier.
-**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Header-only one-level expandability lookahead preserves pre-click stability without reading descendant events, while disclosure remains a lazy authoritative direct-child read; existing Host frames update activity, restore expandable parent rows, and trigger bounded membership refreshes.
+**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Disclosure loads missing catalogs lazily, and existing control frames carry complete projection updates.
**Let a child remain independently interactive after its parent disappears.** Rejected because independent lifetime and user ownership require side-session semantics.
## Testing
-- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
+- Host protocol tests pin healthy direct catalog schemas, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
-- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
-- jsdom tests pin the ordinary-title separator, per-level combined subagent-title switchers, 12px nested titles, current and ancestor styling, selected-row weight, catalog-label precedence, hover delay, upward-click suppression, truncation with a retained chevron, the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
-- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the LLM seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
+- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity from Session summaries and Agent disposal, authoritative catalog membership, and membership refresh.
+- jsdom tests pin the ordinary-title separator, per-level combined subagent-title switchers, 12px nested titles, current and ancestor styling, selected-row weight, catalog-label precedence, hover delay, upward-click suppression, truncation with a retained chevron, direct counts and activity from catalogs, sidebar direct-child activity, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, generic loading, mixed-mode rows, ready-empty leaf detection, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
+- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins direct catalog counts, usage and timing rows, adaptive long-duration presentation, and lazy nested expansion, opens persisted history without activation, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the LLM seam while it pins the direct running state in both the header and visible idle owner row, then cancels the turn during teardown.
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
## Consequences
-- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight.
-- Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
+- Catalog reads fold only the selected parent's catalog projection. Web activity comes from Session summaries, while usage and duration reuse projection baselines and pushes with no per-row log read; projection reads remain single-flight.
+- Parent availability and child activity are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
- A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path.
- Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut.
- Beyond the current-turn Stop of a running continuable child ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)), the UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
index 0f4f646748..4531528dd2 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
@@ -10,12 +10,14 @@ Status: implemented
浏览器必须遵守[可继续 subagent 约定](../../implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
-UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的成员、mode 与 diagnostic。共享服务报告采用实时优先规则的语料活动状态,而 Web 投影会将其替换为确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
+UI 还必须保留[持久化目录](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)的成员与 mode。Web 投影会添加确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
## 决策
Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。
+同一个页头行还可以把 child 作为 `dsh-resource://subagentchat/session/?parent=&mode=` 在右侧 Sidebar 打开。打开时优先使用独立分栏;无法分栏时回退到当前分栏。Sidebar tab 使用共享 Conversation Component Factory 渲染并省略宽度控制,因此主对话与嵌入式对话共用一套组装,而不共享布局界面。
+
每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。
通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
@@ -30,16 +32,16 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
| 设计意图 | 已交付约定 |
| --- | --- |
-| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
+| 会话页头可打开紧凑的 child 列表。 | 触发器统计 parent catalog service 返回的健康直接条目。 |
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
-| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
+| 嵌套 agent 会逐层展开。 | row 在自身 catalog 成功加载为空前保持可展开;展开时只加载该直接 catalog,并保留 row 的 parent 地址。 |
| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何能力。 |
## 产品约定
-只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
+直接 catalog 有子项或读取失败时显示 child 数量控件;空目录缺席、加载中或成功读取为空时均隐藏该控件。普通会话会用斜杠分隔当前 title 和该控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于可选的会话摘要 title,缺失的切换器目录在交互时加载。只有当前 subagent 会追加自己的直接 child 数量控件。过长的 title 会在固定箭头之前截断。触发器报告直接 catalog 的总数与运行数。普通侧边栏 row 隐藏 origin 为 subagent 的 Session,并从同一份已加载 catalog map 读取直接运行 child 数;Session summary 提供 activity,但绝不创建 membership。运行中的直接 child 使用共享的三级灰色 ongoing loading。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而直接 child activity 则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两种主要状态均不存在时,直接 child activity 优先于未查看的完成提醒;最后一个运行中的直接 child 停止后,该提醒会恢复。tree row 在 child catalog 缺席、加载中或失败时保持可展开,只有 catalog 成功加载为空后才成为已知叶子。加载时显示通用提示,不会从 summary 生成占位 row。tree 会呈现 continuable 与 one-shot row;one-shot 的可选 label 缺失时,回退到其 Session id。
-`running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
+`running` 表示子 Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。Session 列表基线与状态事件提供活动状态,移除事件将完成的子代理标为 inactive 并保留其展示 projection。共享的父 `subagentCatalog` projection 提供成员关系。初始读取与推送值进入同一个 projection store,较新序号优先。[projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明加载、重连与同步取舍。提示词响应仍是投递时的权威依据。
健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。
@@ -51,17 +53,17 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
## 宿主适配器与协议约定
-`@deepseek-ai/dsh-subagent` 拥有浏览器安全的生成 `subagent` Remote 命名空间:
+Session Controller 负责 catalog 与 history read;`@deepseek-ai/dsh-subagent` 负责 continuation control:
-- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
-- `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
+- `session.projections` 接受 `sessionId`,返回一次 live-preferred Session observation 的完整 projection 基线,不激活 Agent。Client 将基线写入标准 projection store;subagent 消费者选择 `subagentCatalog`。父 Agent 可用性来自 Session 列表摘要与生命周期事件。
+- `session.page` 与 `session.follow` 接受包含 mode 的完整地址。它们在观察到的 cut 上校验 child header、直接 parent、descriptor identity 与 mode,随后在不发布 Agent 的情况下返回普通 raw event、pagination、live reconciliation 与 Host projection baseline。
- `subagent.prompt` 只接受 `mode: 'continuable'` 地址、`delivery: 'queue' | 'steer'` 与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,使用 continuation manager 共享的人类投递准入,并返回已接受的 `MessageId`。
-网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
+网关会将 parent 或目录条目缺失、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
-查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
+查看持久化历史本身不会创建 Agent。当后续消息物化冷态 child Activation 时,现有 Host 与 Session journal stream 会发布其生命周期与事件。重新连接时,系统通过 `session.follow` 重建已寻址窗口。
-普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡。
+普通 `session.page` 与 `session.follow` 地址对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡。
适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
@@ -71,7 +73,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支、焦点与悬停计时器。`ui-conversation` 为当前普通 title 和每一级 subagent 面包屑声明谱系 slot,传入纯数据形式的面包屑身份与显示文本,并为祖先传入向上导航回调;普通 title 由 render site 保留为回退。`@deepseek-ai/dsh-client-ui-subagent` 以直接 parent 目录导航占用每个谱系 slot,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
-每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。同一条现有的 `host/session-added` 帧还会把已加载的直接 parent 行标记为可展开,而无需引入目录事件流。描述符 mode 与目录校验仍然是导航、继续执行和授权的权威依据。
+每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。parent catalog projection 拥有 membership 与 tree structure 权威;descriptor identity 与精确 parent check 拥有 addressed history 与 continuation validation 权威。
该包现有的 `@label` source 仍然是独立的面向模型纯文本输入。它不会将 label 解析为地址,也不会获得继续执行语义。
@@ -95,23 +97,23 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
**根据谱系推断 mode 或侧边栏过滤。** 不予采纳,因为普通 fork 共享 `parentSession`。由描述符支撑的目录负责提供 mode;单独的 `origin` 标记只是低成本的导航分类器。
-**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。仅用于页头的一层可展开性预查会在不读取后代事件的情况下保证点击前的稳定性,而展开仍是懒加载式权威直接 child 读取;现有 Host 帧会更新活动状态、恢复 parent 行的可展开性,并触发有界的成员刷新。
+**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。展开控件按需加载缺失目录,现有 control 帧传递完整 projection 更新。
**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为独立生命周期与用户所有权需要 side session 语义。
## 测试
-- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
+- 宿主协议测试固定健康的直接 catalog schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
-- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
-- jsdom 测试固定普通 title 分隔符、逐级合并的 subagent title 切换热区、嵌套 title 的 12px 字号、当前与祖先样式、选中行字重、目录 label 优先级、悬停延迟、向上点击抑制、保留箭头的截断、后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
-- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 LLM seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
+- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、来自 Session 摘要与 Agent 销毁的实时活动状态、权威 catalog membership 与成员刷新。
+- jsdom 测试固定普通 title 分隔符、逐级合并的 subagent title 切换热区、嵌套 title 的 12px 字号、当前与祖先样式、选中行字重、目录 label 优先级、悬停延迟、向上点击抑制、保留箭头的截断、catalog 直接计数与 activity、侧边栏直接 child activity、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、通用加载提示、混合 mode row、ready-empty 叶子判定、descendant 懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
+- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定直接 catalog 数量、token 用量与计时行、自适应长耗时呈现及嵌套懒加载,在不激活的情况下打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 LLM seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner row 中的直接运行状态,随后在 teardown 期间取消该轮次。
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
## 后果
-- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
-- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose(资源释放)、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
+- catalog read 只 fold 所选 parent 的 catalog projection。Web activity 来自 Session 摘要;token 用量与耗时复用 projection baseline 和 push,无需按 row 读取日志,投影读取保持 single-flight。
+- parent 可用性与 child activity 都是快照。列出之后,发布、dispose(资源释放)、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
- 除对正在运行的可继续 child 的当前轮次 Stop([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些能力已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
deleted file mode 100644
index 5bbe91681e..0000000000
--- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling
-
-Status: implemented
-
-English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md)
-
-## Problem
-
-Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle.
-
-## Decision
-
-The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row.
-
-Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header.
-
-The pinned header's stacking rank differs by block, because their bodies differ. The Think body uses [compact Markdown](../bug-fix/2026-09-17-thinking-markdown.md) with code banners in normal flow, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
-
-The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state.
-
-No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components.
-
-## Alternatives considered
-
-**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable.
-
-**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency.
-
-**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling.
-
-**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block.
-
-## Consequences
-
-The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets.
-
-## Testing
-
-The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration.
-
-The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls.
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md
deleted file mode 100644
index c75846fa7f..0000000000
--- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住
-
-Status: implemented
-
-[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文
-
-## 问题
-
-会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。
-
-## 决策
-
-每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。
-
-这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。
-
-钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文使用[紧凑 Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md),代码栏处于正常文档流中,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
-
-规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。
-
-不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。
-
-## 曾考虑的替代方案
-
-**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。
-
-**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。
-
-**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。
-
-**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。
-
-## 后果
-
-很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。
-
-## 测试
-
-单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。
-
-真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
index ab88dedfc2..a4f2c8968a 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
-2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c
-2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464
+2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc
+2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
index bdf646db9b..45c7e599e0 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
@@ -40,7 +40,9 @@ Fresh children have no inherited conversation. Fork children capture the Lead's
Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds.
-`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
+Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Locally declared content variants undergo structural validation; opaque plugin fields do not gain Team semantics. Cache invalidation is necessary because parser correction alone cannot recover keys omitted from cached state. The [Team documentation](../../../../packages/experimental/agent-team/README.md) owns the restore behavior.
+
+`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write.
@@ -72,6 +74,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs.
+Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct.
+
## Consequences
The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies.
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
index 5386044edb..dbb2931bdc 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
@@ -40,7 +40,9 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前
Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。
-`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
+事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。本地声明的内容变体接受结构校验;不透明的插件字段不获得 Team 语义。缓存失效是必要的,因为只修正解析器无法恢复缓存状态中省略的键。[Team 文档](../../../../packages/experimental/agent-team/README.zh.md)拥有恢复行为的说明。
+
+`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。
@@ -72,6 +74,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。
+模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。
+
## Consequences
Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。
diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml
index c79578f47b..a8b84e3d55 100644
--- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-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/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
-2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56
-2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea
+2026-08-06-manager-owned-subagent-settlement-delivery.md: 7f6cfc90862b5bb32368b4b0e1fbe068ba0cbf66
+2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 23c5506f79d4d95308b0533e8d507ac856c4d85e
diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
index 5553d4c06a..7f6cfc9086 100644
--- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
+++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
@@ -90,7 +90,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t
**Filter the canonical child output.** Removing nontext blocks in `AssistantOutputFold` would discard assistant content needed by SDK and UI consumers. Only the parent notice requires a text projection.
-**Relax the Messages serializer.** Accepting or silently discarding invalid user-role blocks would conceal the producer's role conversion error. Notice construction supplies content that every parent provider can represent while protocol validation remains strict.
+**Rely on serializer tolerance for notice construction.** Notice construction supplies text that every parent provider can represent; adapter filtering cannot replace that producer responsibility. The [saved-input compatibility decision](../bug-fix/2026-09-18-messages-input-history-compatibility.md) supersedes the rejection of Messages input filtering for already recorded notices, which a producer fix cannot change.
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
index 6d7ee756ad..23c5506f79 100644
--- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
@@ -90,7 +90,7 @@ Status: implemented
**过滤规范的子级输出。** 在 `AssistantOutputFold` 中删除非文本块,会丢弃 SDK 和 UI 消费方需要的 assistant 内容。只有父级通知需要文本投影。
-**放宽 Messages 序列化器。** 接受或静默丢弃用户角色中的无效块,会掩盖生产方的角色转换错误。通知构建提供每种父级提供方都能表示的内容,协议校验仍保持严格。
+**依赖序列化器容错来构建通知。** 通知构建提供每种父级提供方都能表示的文本;适配器过滤不能替代生产方的责任。[已保存输入兼容决策](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)取代了对已记录通知进行 Messages 输入过滤的拒绝,因为生产方修复无法改变这些通知。
## 后果
diff --git a/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.i18n.yaml
new file mode 100644
index 0000000000..792f97a11a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md
+2026-08-18-arbitrary-seq-session-fork.md: c02ba6c1f24462224cb7f4f245d82063f29c4d61
+2026-08-18-arbitrary-seq-session-fork.zh.md: fe78e505313b2177435abd93e1d8056972b2646b
diff --git a/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md
new file mode 100644
index 0000000000..c02ba6c1f2
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md
@@ -0,0 +1,39 @@
+# Agent Note: Exact-event session forks with shared crash repair
+
+Status: implemented
+
+English | [中文](2026-08-18-arbitrary-seq-session-fork.zh.md)
+
+## Problem
+
+A branch may need the history before a tool result arrives, including a historical position in a parent that has since completed. Restricting forks to completed turns prevents that choice. Reusing crash-recovery result wording would falsely imply that the parent was interrupted.
+
+## Decision
+
+`SessionStore.fork` accepts any existing integer event seq exactly. It owns source membership, child identity and cut validation. `buildForkSeed`, the only export of `dsh-session/fork`, copies the inclusive prefix, inserts the child's inherited `session/end-seed` marker, and closes the open tail. The Host selects and builds from one immutable observation. Omitted `atSeq` means the latest completed turn with its independent tail, stopping before the next turn or queued user input. An explicit seq never moves.
+
+Fork and crash recovery share the same tool-pairing algorithm. Only an open step receives missing error results before `step/end`; an open turn then receives `turn/end`. Closed steps and turns remain unchanged, including historical failures with missing results. The internal cause selects `forked` versus `interrupted`, deterministic result IDs and model-visible wording. Fork wording describes missing inherited records and warns that the parent may have executed the call after the cut. Retry advice distinguishes read-only or idempotent operations from operations with side effects.
+
+The inherited marker precedes synthetic closers. `inheritedEventCount` counts only copied parent events; the marker and closers are child-owned and are persisted before agent publication. A constructor accepts this complete seed only when its supplied cut identifies the final inherited marker. Nested forks retain ancestor markers within their copied prefix. `firstLiveSeq` remains the length of the complete constructor input, distinct from durable inheritance.
+
+V4 admits not-started fork results without changing released V0–V3 validators. Its codec and relationship validator use a private canonical interrupted-result view for this checked variant, then return the original fork ID and message. The V3-to-V4 migration preserves existing events. The `forked` turn-end reason is acknowledged with the finalized V4 header transition.
+
+This replaces the [completed-turn-only controller policy](../../archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md). Explicit cuts retain the requested event exactly; omitted cuts retain completed standalone work, including manual compaction replacements, until a core-owned turn or queued-input event begins. Plugins continue to own their brackets; the controller does not classify plugin events.
+
+## Alternatives considered
+
+**Move the cut to a completed step.** This changes an explicit historical choice and loses the requested context. Exact cuts with logged closers preserve that context.
+
+**Repair during request construction.** Model-visible results must be logged, so the seed records them before the model request.
+
+**Repair calls in closed steps.** Those failures already belong to the parent; this would expand fork into a new historical repair policy and diverge from crash recovery.
+
+**Expose a Store-dependent fork wrapper.** Store creation rules remain in Store; the pure seed helper needs only event types and the internal closer, avoiding another public entry and a Store file move.
+
+## Consequences
+
+Fork closes the selected open tail, not earlier malformed history. Plugins own their unmatched brackets: no `compaction/end` is synthesized. The inherited marker lets compaction recognize an inherited stale start. Parent and child sequence numbers diverge after the cut. For a new fork, `firstLifecycleSeq` starts at the child-owned marker, so telemetry includes synthetic results and closers. Restored Sessions start this lifecycle after their stored prefix; the durable fork cut alone does not identify new capture work. The chat action retains completed-turn selection; finer event selection is backend functionality and adds no side chat.
+
+## Testing
+
+Session and repair tests cover exact cuts, both error classifications, closed failed steps, empty histories, plugin brackets and surface replacements. Agent-loop regressions verify the actual next model request, preserved results, turn numbering and no repeated tool execution. Host tests cover live and cold sources, exact rejection and queued-input exclusion. V4 tests round-trip original fork result data, reject malformed results and validate nested forks. The keyless `fork-mid-turn` browser scenario forks a cold recording through HTTP and continues through the composer with the branch wording rendered in its expected output.
diff --git a/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md
new file mode 100644
index 0000000000..fe78e50531
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md
@@ -0,0 +1,39 @@
+# Agent Note:复用崩溃修复的精确事件会话分叉
+
+Status: implemented
+
+[English](2026-08-18-arbitrary-seq-session-fork.md) | 中文
+
+## Problem
+
+分支可能需要工具结果到来之前的历史,包括父会话已经完成后的历史位置。将 fork 限制到已完成轮次会阻止这种选择。复用崩溃恢复的结果文案则会错误暗示父会话已中断。
+
+## Decision
+
+`SessionStore.fork` 精确接受任意已有的整数事件 seq,并拥有源会话归属、子会话标识和切点校验。`dsh-session/fork` 唯一导出的 `buildForkSeed` 复制含切点的前缀,插入子会话的继承 `session/end-seed` 标记,再关闭开放尾部。Host 从同一份不可变观察中选点和构建。省略 `atSeq` 表示最近已完成轮次及其独立尾部,在下一轮次或排队用户输入前停止。显式 seq 永不移动。
+
+Fork 与崩溃恢复共用相同的工具配对算法。只有开放步骤会在 `step/end` 前补缺失的错误结果;开放轮次随后补 `turn/end`。已关闭的步骤和轮次保持原样,包括缺少结果的历史失败。内部 cause 选择 `forked` 或 `interrupted`、确定性的结果 ID 及模型可见文案。Fork 文案描述缺少的继承记录,并提醒父会话可能已在切点后执行调用。重试建议区分只读或幂等操作与有副作用的操作。
+
+继承标记位于合成结束事件之前。`inheritedEventCount` 只统计复制的父事件;标记与结束事件属于子会话,在 agent 发布前持久化。构造函数仅在给定切点指向最后一个继承标记时接受这种完整 seed。嵌套 fork 在复制前缀中保留祖先标记。`firstLiveSeq` 仍是完整构造输入的长度,与持久继承计数不同。
+
+V4 接受未启动的 fork 结果,不修改已发布的 V0–V3 校验器。其 codec 和关系校验器为这一经过校验的变体提供内部规范 interrupted-result 视图,再返回原始 fork ID 和消息。V3-to-V4 迁移保留已有事件。`forked` 轮次结束原因与已定稿的 V4 头版本变更一起记录兼容性确认。
+
+本决策取代[仅按已结束轮次截取的 Controller 策略](../../archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md)。显式切点精确保留所选事件;省略切点时保留已完成的独立操作,包括手动压缩的替换事件,直到 core 所属的新轮次或排队输入事件开始。插件继续负责自己的括号关系;Controller 不对插件事件分类。
+
+## Alternatives considered
+
+**将切点移到已完成步骤。** 这会改变显式历史选择,丢失所需上下文。精确切点加已记录的结束事件保留该上下文。
+
+**构建请求时修复。** 模型可见结果必须进入日志,因此 seed 在模型请求之前记录它们。
+
+**修复已关闭步骤中的调用。** 这些失败原本属于父会话;这会将 fork 扩展为新的历史修复策略,偏离崩溃恢复行为。
+
+**公开依赖 Store 的 fork 包装函数。** Store 的创建规则留在 Store;纯 seed helper 只需要事件类型和内部结束函数,避免增加公开入口或搬迁 Store 文件。
+
+## Consequences
+
+Fork 关闭所选开放尾部,不修复更早的畸形历史。插件拥有其未配对括号:不会合成 `compaction/end`。继承标记让 compaction 识别继承而来的陈旧 start。父子序号在切点后分离。 新 fork 的 `firstLifecycleSeq` 从子会话自有标记开始,因此 telemetry 包含合成结果与结束事件。恢复的 Session 从已存储前缀之后开始当前生命周期;仅凭持久化 fork 切点无法识别新的采集内容。聊天操作保留已完成轮次选择;更细的事件选择属于后端功能,不增加 side chat。
+
+## Testing
+
+Session 和 repair 测试覆盖精确切点、两种错误分类、已关闭失败步骤、空历史、插件括号和 surface 替换。Agent-loop 回归验证实际的下一次模型请求、结果保留、轮次编号及不重复执行工具。Host 测试覆盖实时与冷源会话、精确拒绝和排队输入隔离。V4 测试往返原始 fork 结果数据、拒绝畸形结果并验证嵌套 fork。无需密钥的 `fork-mid-turn` 浏览器场景通过 HTTP 对冷录制进行 fork,再通过输入框继续运行,并在预期输出中展示分支文案。
diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
index 56fb8a76d2..d5f37e0569 100644
--- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
-2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730
-2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b
+2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba
+2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d
diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
index 6d0d2901b1..ddaf92a396 100644
--- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
+++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
@@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow-
## Testing
Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer.
+
+## Related
+
+The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts.
diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
index 2d78811c24..27a73536ec 100644
--- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
@@ -48,3 +48,7 @@ Status: implemented
## 测试
单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。
+
+## 相关记录
+
+[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。
diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
deleted file mode 100644
index c4e506294c..0000000000
--- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs
-
-Status: implemented
-
-English | [中文](2026-09-04-web-clickable-link-styles.zh.md)
-
-## Problem
-
-Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces.
-
-## Decision
-
-One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
-
-- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
-- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
-- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
-- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis.
-- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
-- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
-
-Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings.
-
-## Alternatives considered
-
-- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
-- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)).
-- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
-- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
-
-## Consequences
-
-- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md).
-- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first.
-- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted.
diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
deleted file mode 100644
index 01258674b4..0000000000
--- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标
-
-Status: implemented
-
-[English](2026-09-04-web-clickable-link-styles.md) | 中文
-
-## 问题
-
-会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。
-
-## 决策
-
-会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
-
-- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
-- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
-- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
-- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。
-- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
-- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
-
-覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。
-
-## 备选方案
-
-- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
-- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。
-- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
-- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
-
-## 后果
-
-- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。
-- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。
-- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。
diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
index a2f18c11cb..702e3bf1fc 100644
--- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
-2026-09-05-sidebar-text-preview-and-file-tree.md: 0142b325004a1fc018e944ed908d600e75f0ab0d
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: d40ca8c790c9a7208a2243422c0973fc293a5915
+2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554
diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
index 0142b32500..8ddb1a3363 100644
--- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
+++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
@@ -20,7 +20,7 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the *
The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open.
-The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened.
+The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened.
The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident.
@@ -48,7 +48,7 @@ A failed read keeps content already shown and adds a localized failure with a re
### The file tree
-`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [guide refinement](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns that presentation choice. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does.
+`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes that presentation rule. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does.
The root is the session's working directory as the Host reports it in the session list (`useSessions().byId[sessionId].cwd`), labelled by `workspaceTitleOf` from `dsh-util-workspace-path` — the final non-empty path segment — with the root string itself as the label when the path is separator-only. A session without a working directory shows one line (`noWorkspace`) and issues no request. There is no root chooser and no way to browse upward: the Host's `list` refuses paths outside the Session's workspace root, so the one directory the client can list is the one it shows.
diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
index d40ca8c790..294a2b4560 100644
--- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
@@ -20,7 +20,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。
-正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
+正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。
@@ -48,7 +48,7 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持
### 文件树
-`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[引导页细化记录](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责这项展示选择。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。
+`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明这项展示规则。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。
根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。
diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
index 6be2091ff6..d4ef512e91 100644
--- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
-2026-09-07-composer-session-stats-pills.md: 541e5a363d78c4333b1b8e3927928a19754822fa
-2026-09-07-composer-session-stats-pills.zh.md: b6ef6da3c37a46d9e07e7ebdf77439e26896fddc
+2026-09-07-composer-session-stats-pills.md: 50d3d3294ed012773657ba9849e45835c361d8be
+2026-09-07-composer-session-stats-pills.zh.md: 97b91b601e62e8b06ab13a764babb4dd3b25bfce
diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
index 541e5a363d..50d3d3294e 100644
--- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
+++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
@@ -10,12 +10,15 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
## Decision
+The [performance and usage preference](2026-09-16-performance-usage-preference.md) governs statistic visibility and removes the completed-turn elapsed-time action.
+
`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
-- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
-- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
+- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission.
+- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster.
+- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent.
- **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
-- **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
+- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding.
## Alternatives considered
@@ -27,5 +30,5 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
- `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename.
- Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings.
-- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
+- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin time, billing, and context order below the submit action, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
- The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`.
diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
index b6ef6da3c3..97b91b601e 100644
--- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
@@ -10,12 +10,15 @@ Status: implemented
## 决定
+[性能与用量偏好](2026-09-16-performance-usage-preference.zh.md)管理统计可见性,并移除已完成轮次的耗时操作。
+
`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
-- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
-- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。
+- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。
+- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。
- **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
-- **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
+- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。
## 备选方案
@@ -27,5 +30,5 @@ Status: implemented
- `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
- 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。
-- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
+- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住提交操作下方的时间、计费、上下文顺序,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
- 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。
diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
index ddba9ebf1e..d726d38ae1 100644
--- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b
-2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78
+2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed
+2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5
diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
index c667ec3afd..b79c252646 100644
--- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
+++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
@@ -18,6 +18,8 @@ Assistant blocks remain the durable model-visible content. A versioned `ReplayEn
Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
+JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields.
+
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. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment.
@@ -36,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20
## 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. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
+The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
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.
diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
index 0e3425e41d..49a967828e 100644
--- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
@@ -18,6 +18,8 @@ Status: implemented
两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
+成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。
+
系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](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`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
@@ -36,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU
## 结果
-该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
+该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml
index c52f0a47a2..daa947a20c 100644
--- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
-2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd
-2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840
+2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb
+2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7
diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
index ed740acd0d..fe769d1cbe 100644
--- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
+++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
@@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This
`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again.
-`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
+`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation.
diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md
index de8a7f99d7..2cfff4016f 100644
--- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md
@@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录
`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。
-`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
+`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。
diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
index a42e0ee0bd..93a617d08b 100644
--- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
-2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
-2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224
+2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52
diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
index 3cccb3c038..50b97ce2c7 100644
--- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
+++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
@@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/
The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
-`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
+`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
-The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
+The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
index 31c879b17a..92bcb04bfe 100644
--- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
@@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
-`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
+`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
-菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
+菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
index 16c17f3405..c18c6397ed 100644
--- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
-2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31
-2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0
+2026-09-08-feedback-dialog-and-categories.md: 1d544e6ef978c10008ac62768b6faaf9eaaf8b73
+2026-09-08-feedback-dialog-and-categories.zh.md: 6e58820bf308a3a9f7d15b64027f90e7e6771bd4
diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
index ee656946a9..1d544e6ef9 100644
--- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
+++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
@@ -14,10 +14,14 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe
`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor.
+The Session Header's more-actions menu also opens the Session dialog through the `feedbackUi.openSession` Client service. The menu remains owned by `session-log-export`, while the existing per-session feedback controller owns the draft and submission. An observable tracks the optional feedback plugin's availability, so unloading feedback removes only its menu row and leaves export usable. Opening or dismissing the form writes no feedback event; Submit records one Session remark.
+
The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure.
## Alternatives considered
+**A child slot for Header menu actions.** A slot would let each plugin own its menu row, but requires a shared menu-item API beyond the feedback entry requested in #4445. The optional `feedbackUi` service keeps this change local while leaving dialog state and submission in the feedback plugin.
+
**Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by.
**Submit the dialog through the command plane as `/feedback `.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint.
diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
index ef2b2d4670..6e58820bf3 100644
--- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
@@ -14,10 +14,14 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。
+会话标题栏的更多操作菜单也通过 `feedbackUi.openSession` Client 服务打开 Session 弹窗。菜单仍由 `session-log-export` 持有,现有的逐 Session 反馈控制器持有草稿与提交。一个可观察值跟踪可选反馈插件的可用状态,因此卸载反馈插件只移除其菜单行,导出仍然可用。打开或关闭表单不写反馈事件;点击提交才记录一条 Session 反馈。
+
弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。
## 考虑过的替代方案
+**为标题栏菜单动作声明子 slot。** slot 可以让每个插件持有自己的菜单行,但需要共享菜单项 API,超出 #4445 所需反馈入口的范围。可选的 `feedbackUi` 服务将改动限制在局部,同时让弹窗状态与提交继续由反馈插件持有。
+
**把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。
**让弹窗经命令平面以 `/feedback ` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
index 0458b32e17..057e138853 100644
--- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf
-2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b
+2026-09-08-present-workspace-source-files.md: 52cc3e9cdbcd30d378117a327b86062523a2cb3e
+2026-09-08-present-workspace-source-files.zh.md: 99b7b9df1d9ca2da2175df1ae7e9d8acbb5c5fdd
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
index e1bc2bbc56..52cc3e9cdb 100644
--- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
+++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
@@ -12,7 +12,7 @@ Users need to open and edit the files produced in their workspace, including she
The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
-The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
+The [tool description](../../../../packages/deliverables/tool-present/README.md#model-experience) guides the model's choice of files for explicit delivery. A declaration remains distinct from a prose file reference. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) covers the resulting file, delivery event, and card. New recordings explicitly request a separate file card; replay preserves the recorded request. This scenario verifies explicit delivery, not a requirement to call `present` for every SVG. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
index e4f21172b3..99b7b9df1d 100644
--- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
@@ -12,7 +12,7 @@ Status: implemented
[present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
-工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
+[工具说明](../../../../packages/deliverables/tool-present/README.zh.md#model-experience)指导模型选择需要显式交付的文件。交付声明与正文中的文件引用各自独立。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)覆盖生成文件、交付事件和卡片。新录制明确请求独立文件卡片,回放保留录制时的请求。该场景验证显式交付,不要求所有 SVG 都调用 `present`。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
index bf3876efca..35f8f4dc56 100644
--- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
-2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f
-2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2
+2026-09-08-shared-file-type-icons.md: 5dbe0ade14c15e1d509f56eba6e0266c61089c46
+2026-09-08-shared-file-type-icons.zh.md: 9dbccdf10f188b30dcbbbe11d45b238af42ca112
diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
index 1e3fe72679..5dbe0ade14 100644
--- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
+++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
@@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md)
## Problem
-Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins.
+Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins.
## Decision
`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`.
-`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
+`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
-Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
+Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
-`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
+`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [link styling rules](../../../../docs/web-styling.md) does not change.
-Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
+Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces.
## Alternatives considered
-**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities.
+**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities.
-**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics.
+**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics.
**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette.
@@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac
## Testing
-The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
+The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
index fd1f38ebb3..9dbccdf10f 100644
--- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
@@ -6,25 +6,25 @@ Status: implemented
## 问题
-客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。
+客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。
## 决策
`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。
-`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
+`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
-已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
+已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
-`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
+`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[链接样式规则](../../../../docs/web-styling.zh.md)定义的 14px 外观不变。
-附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
+附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。
## 备选方案
-**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。
+**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。
-**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。
+**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。
**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。
@@ -32,7 +32,7 @@ Status: implemented
## 测试
-`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
+`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
## 后果
diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
deleted file mode 100644
index f0f5cb478d..0000000000
--- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: Turn duration labels gain an hour unit
-
-Status: implemented
-
-English | [中文](2026-09-09-turn-duration-hour-unit.zh.md)
-
-## Problem
-
-The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour.
-
-## Decision
-
-`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys.
-
-The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format.
-
-## Alternatives considered
-
-**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running.
-
-**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles.
-
-**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change.
-
-**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs.
-
-## Consequences
-
-Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears.
diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
deleted file mode 100644
index c9c94eef2a..0000000000
--- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: 回合耗时标签增加小时单位
-
-Status: implemented
-
-[English](2026-09-09-turn-duration-hour-unit.md) | 中文
-
-## 问题
-
-Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。
-
-## 决定
-
-`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。
-
-改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。
-
-## 考虑过的替代方案
-
-**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。
-
-**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。
-
-**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。
-
-**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。
-
-## 后果
-
-长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
index af7fc09ddb..cfbf30d80a 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: 5757aed3702dbcc7752e99912714bd1356a96206
-2026-09-09-web-sidebar-terminal.zh.md: 39870d1ba26cbc1d655b35a3d2dba61692d0fdb9
+2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d
+2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
index 5757aed370..5ed920d744 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
@@ -16,7 +16,7 @@ The application theme supplies terminal default colors. The body reads resolved
`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider; [user-terminal permissions](../architecture/2026-09-16-user-terminal-permissions.md) govern their execution permissions independently of the Agent. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
-Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
+Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. Cleanup failures have no notification or manual retry action. Host terminals missing from the saved layout are not reopened automatically and have no UI recovery entry. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
[Sidebar layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns browser reload: layout and tab identities are restored before the terminal provider reconnects its views. Listing still takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Only a new view may allocate a process; a recovered target that disappears reports an error. The Host remains authoritative for process state, titles and screen contents.
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
index 39870d1ba2..c3ceb0214c 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
@@ -16,7 +16,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider;[用户终端权限](../architecture/2026-09-16-user-terminal-permissions.zh.md)规定其独立于 Agent 的执行权限。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
-关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败没有通知或手动重试操作。保存布局之外的 Host 终端不会自动重新打开,也没有 UI 恢复入口。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
[侧栏布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责浏览器刷新:先恢复布局和标签身份,再由 terminal provider 重连视图。列表仍直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。只有新视图可以分配进程,恢复目标消失时显示错误。进程状态、标题和屏幕内容仍以 Host 为准。
diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
deleted file mode 100644
index 3b6b4c7fd0..0000000000
--- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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.
diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md
deleted file mode 100644
index 0bd5dc3e0b..0000000000
--- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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 重述了该交互。
diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml
deleted file mode 100644
index dd2b16d82c..0000000000
--- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-guide-start-page-and-stat-pill-refinements.md
-2026-09-10-guide-start-page-and-stat-pill-refinements.md: 987e46fd597b11046843a943c60187fdb1a769d0
-2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: 590f18a2963507a8918a0aca30997aac23c6e477
diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
deleted file mode 100644
index 987e46fd59..0000000000
--- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: Guide start page and stat pill refinements
-
-Status: implemented
-
-English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)
-
-## Problem
-
-The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache.
-
-## Decision
-
-**The guide is a compass over self-describing capsules.** This refines the guide contracts in [Right Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) and [Sidebar text preview and file tree](2026-09-05-sidebar-text-preview-and-file-tree.md). [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines.
-
-**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx).
-
-**The session usage dialog drops the zero cache-write row.** This refines [Composer session stats](2026-09-07-composer-session-stats-pills.md). [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows.
-
-## Alternatives considered
-
-**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs.
-
-**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest.
-
-**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement.
-
-## Consequences
-
-`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules.
diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md
deleted file mode 100644
index 590f18a296..0000000000
--- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: 引导起始页与统计 pill 的细化
-
-Status: implemented
-
-[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文
-
-## Problem
-
-右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外,输入框下方的会话 token 用量弹窗会给从未写入缓存的会话打印一行 `Cache write 0 tok`。
-
-## Decision
-
-**引导页是罗盘压着能自我说明的胶囊。** 本决议细化[右侧 Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)和 [Sidebar 文本预览与文件树](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的引导页约定。[GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。
-
-**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。
-
-**会话用量弹窗去掉为零的缓存写入行。** 本决议细化[输入框下的会话统计](2026-09-07-composer-session-stats-pills.zh.md)。[StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。
-
-## Alternatives considered
-
-**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。
-
-**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。
-
-**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。
-
-## Consequences
-
-`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。
diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml
new file mode 100644
index 0000000000..16d943e2a3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
+2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f
+2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226
diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
new file mode 100644
index 0000000000..d66d325736
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
@@ -0,0 +1,67 @@
+# Agent Note: Desktop mandatory-update client and blocking window
+
+Status: implemented
+
+English | [中文](2026-09-11-desktop-mandatory-update-client.zh.md)
+
+## Problem
+
+A local business server does not deliver remote minimum-version policy. The Desktop shell must independently learn that an update is required, block subsequent interaction without interrupting existing tasks, and retain a recovery path when policy queries or updater preparation fail.
+
+## Decision
+
+The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy.
+
+A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
+
+User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
+
+A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available.
+
+The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval.
+
+The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target.
+
+Background readiness requests parent-window taskbar attention on Windows or an informational Dock bounce on macOS and attempts one silent notification. Returning to the current confirmation clears native reminders without granting installation permission. Repeated state, deferral, and focus changes do not repeat a readiness reminder. Policy clearance, installation, and disposal also clear it. Unsupported or denied notifications leave the modal usable; native display requires installed-platform qualification.
+
+This decision implements the client portion of the [interaction proposal](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.md). That proposal and the API proposal remain active for backend integration, platform release qualification, and unresolved product decisions. The [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) retains artifact signing and publication requirements; none of these records is fully superseded.
+
+Host exit and successful task teardown are separate outcomes. The process owner reports confirmed but unsuccessful exit with a dedicated error type. Backend cleanup can complete for that outcome while installation still fails; the shell restores the current-version Host and requires fresh installation confirmation without clearing mandatory policy. The replacement gets a fresh Web origin and authentication cookie, so the existing application URL reloads after Host readiness and obtains new boot data even when the port is unchanged. Other stop failures retain the backend's cleanup failure and prevent replacement, because the old process may still own profile state.
+
+For confirmed unsuccessful teardown, localized recovery guidance remains separate from expandable technical details in both dialog types. The disclosure is closed initially and does not grant update permission. Diagnostics expose process lifecycle facts rather than arbitrary plugin stderr, which can contain credentials. The mandatory dialog preserves an expanded disclosure on unchanged state and collapses it when its diagnostic changes or clears.
+
+Task warnings describe running agents, queued input, and running or stopping jobs, not request traffic. After installation approval, the Host refuses new API requests, waits for admitted requests to finish, and checks tasks again. This preserves admitted writes without warning about page reads. A timed-out or superseded drain cannot authorize installation; the shell unlocks admission after failure.
+
+The product preload publishes semantic updater phase, version, progress, and classified failure fields without localized strings or raw diagnostics. Preparation failures carry a typed cause so native and Web presentations select their own locale's guidance without comparing translated messages. The account-row and collapsed-sidebar presentations resolve that data through the active Web locale, while native dialogs retain the Desktop shell locale. Intentional application quit hides the product window before Host teardown and ignores focus requests until exit, so the expected connection loss cannot appear as recovery work.
+
+## Alternatives considered
+
+**Use the system browser without a gateway handoff.** Its cookies are not shared with Electron requests. Explicit test deployments instead use a sandboxed BrowserWindow and a dedicated in-memory Session, without Node integration or a preload. Document navigation permits only the configured HTTPS policy origin and the reviewed Feishu origins; permissions, downloads, new windows, and HTTP authentication are denied. The gateway needs no new token-exchange endpoint. This trades persistent sign-in and unrestricted identity-provider navigation for isolation. The default anonymous protocol and updater transport are unchanged.
+
+**Keep DevTools disabled in the test login window.** This prevents local inspection of the third-party page but leaves authentication failures difficult to diagnose. F12 is an explicit local action on that window; ordinary login does not open DevTools.
+
+**Treat a completed login redirect as an update decision.** The Windows gateway probe changed from unauthenticated 401 to application-level 422 and reused its Session after closing the login window. That establishes same-process cookie transport, not compatibility with the Harness policy API. The client rechecks policy after login, retains known blocks on authentication expiry or malformed responses, and never uses the gateway-provided login URL. User confirmation precedes every login window. The initial packaged startup check can explain the authentication requirement before the local backend is ready; later automatic checks stay silent. One operation owns explanation, login, and recheck, so concurrent entry points cannot create competing login windows.
+
+**Make policy availability a precondition for ordinary update feedback.** A policy transport or protocol failure would suppress a valid updater result and make ordinary package delivery depend on the enforcement service. The two checks therefore share a user trigger but keep separate result handling; only an established block can preempt ordinary presentation.
+
+**Wait for business APIs to return a version error.** Desktop business traffic is local, so remote policy must have an independent guest request.
+
+**Use a cancellable ordinary message box.** It does not own persistent download progress, retry, page fallback, and policy refresh. A dedicated modal keeps these actions available without permitting cancellation of the requirement.
+
+**Terminate tasks when policy arrives.** Policy requires future interaction to stop, not already-running work. Installation approval remains the separate authorization to stop affected tasks.
+
+**Let the policy select or revoke updater packages.** This creates a second artifact authority and contradicts the approved higher-version replacement strategy. The updater alone selects and validates its version-bound package.
+
+**Keep technical details only in logs.** In-dialog disclosure lets users inspect a stop failure without locating local logs, while the default recovery message stays readable.
+
+**Warn for every in-flight API request.** Startup reads produce a task-interruption warning without affected tasks. Separating request draining from task inspection avoids that warning without classifying RPCs by URL or abandoning pending writes.
+
+**Send localized update strings through the preload.** The Desktop shell locale can differ from the language selected inside the Web application, and transported strings cannot react to a later language change. Semantic presentation data keeps user-selected Web language ownership with the component.
+
+**Keep the product window visible during ordinary quit.** Closing the owned Host first exposes its expected disconnect to the connection-recovery UI. Hiding the window before teardown preserves graceful process cleanup without presenting a retry action for intentional shutdown.
+
+## Consequences
+
+Ordinary check and download failures separate locale-owned summaries from expandable updater diagnostics. Hover feedback contains no raw error text; download hover identifies the operation and target version. Web update status follows the active in-application locale independently of native dialog language. During shell-reported installation, update status takes precedence over connection feedback because stopping the owned backend is expected. An installation failure clears that priority so genuine reconnection remains visible. Ordinary quit removes the product window before the same expected backend disconnect. Long ordinary diagnostics scroll independently of the confirmation action; disclosure does not authorize installation.
+
+The [local qualification](../testing/2026-09-10-desktop-local-updater-qualification.md) executes the actual Electron modal, restricted preload, renderer button handlers, network policy, and updater download. The main-entry regression verifies retained Host work and rejected business controls. A Chinese owner-local DOM expectation guards visible copy and actions. These tests do not establish production API compatibility, native browser/clipboard integration, complete-workspace visuals, signed installation, or post-restart health; [verification records](../../../../apps/desktop/tests/README.md) identify remaining evidence.
diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md
new file mode 100644
index 0000000000..fd6e751a40
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md
@@ -0,0 +1,67 @@
+# Agent Note: Desktop 强更客户端与阻塞窗口
+
+Status: implemented
+
+[English](2026-09-11-desktop-mandatory-update-client.md) | 中文
+
+## 问题
+
+本地业务 server 不会传递远程最低版本策略。Desktop 壳必须独立获知强更要求,在不中断现有任务的情况下阻止后续交互,并在策略查询或 updater 准备失败时保留恢复路径。
+
+## 决策
+
+壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。
+
+协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
+
+用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
+
+壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。
+
+模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。
+
+恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。
+
+后台就绪时,Windows 请求父窗口任务栏提醒,macOS 请求信息级 Dock 弹跳,并尝试一次无声通知。返回当前确认界面会清理原生提醒,不授予安装权限。重复状态、稍后更新和焦点变化不会重复提醒同一次就绪。策略解除、安装和资源释放也会清理提醒。通知不受支持或被拒绝时仍可使用弹窗;原生展示需通过安装包平台验收。
+
+此决策实现[交互提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)的客户端部分。该提案与接口提案仍负责后端联调、各平台发布验收和未决产品事项。[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留产物签名和发布要求;这些记录均未被完整取代。
+
+Host 退出与任务成功收尾是两个独立结果。进程所有者通过专用错误类型报告已确认但非正常的退出。该结果允许后端完成清理,但安装仍失败;壳恢复当前版本 Host,并要求重新确认安装,不清除强更策略。替代 Host 会获得新的 Web 源站和鉴权 Cookie,因此 Host 就绪后会重新加载原有应用地址,即使端口未变也会重新获取启动数据。其他停止失败会保留后端清理错误并阻止替代进程启动,因为旧进程可能仍持有 profile 状态。
+
+对于已确认的非正常收尾,两类弹窗都将本地化恢复提示与可展开的技术详情分开展示。详情初始折叠,不授予更新权限。诊断展示进程生命周期事实,而非可能包含凭据的任意插件 stderr。强更弹窗在状态未变时保留已展开的详情,诊断改变或清除时则折叠。
+
+任务警告描述运行中的 agent、排队输入和运行中或停止中的后台任务,而非请求流量。用户批准安装后,Host 拒绝新 API 请求,等待已接收请求结束,再检查任务。这既保护已接收的写操作,也不因页面读取弹出警告。超时或被替代的等待不能授权安装;失败后壳解除准入锁。
+
+产品预加载只发布语义化 updater 阶段、版本、进度和失败类别,不携带本地化字符串或原始诊断。准备阶段失败携带类型化原因,原生与 Web 界面分别选用自身 locale 的提示,无需比较翻译后的文案。账户行和侧栏收起状态通过当前 Web locale 解析这些数据,原生弹窗仍使用 Desktop 壳 locale。主动退出应用时,产品窗口会在 Host 收尾前隐藏,并在进程退出前忽略聚焦请求,避免把预期连接中断展示成恢复操作。
+
+## 考虑过的替代方案
+
+**没有网关交接机制时使用系统浏览器。** 其 Cookie 不会与 Electron 请求共享。显式启用的测试部署改用沙箱 BrowserWindow 和独立内存 Session,不启用 Node 集成或预加载。文档导航只允许配置的 HTTPS 策略源站及已审核的飞书源站;拒绝权限、下载、新窗口和 HTTP 鉴权。网关不需要新增 token 交换接口。这以跨重启登录及不受限的身份提供方导航换取隔离。默认匿名协议及 updater 传输不变。
+
+**在测试登录窗口禁用 DevTools。** 这会阻止本地检查第三方页面,却使鉴权失败难以排查。F12 是该窗口上的显式本地操作;正常登录不会自动打开 DevTools。
+
+**将登录重定向完成视为更新决定。** Windows 网关探针由未鉴权的 401 变为应用层 422,关闭登录窗口后仍复用 Session。这验证了同进程 Cookie 传输,并不证明与 Harness 策略 API 兼容。客户端在登录后重新检查策略,鉴权过期或响应畸形时保留已知阻塞,且从不使用网关提供的登录 URL。每次登录窗口都先经过用户确认。打包应用的首次启动检查可在本地后端就绪前说明鉴权要求;之后的自动检查保持静默。说明、登录和重新检查由同一个操作负责,使并发入口无法创建相互竞争的登录窗口。
+
+**把策略可用作为常规更新反馈的前置条件。** 策略传输或协议失败会压制有效的 updater 结果,并使常规产物分发依赖强更服务。因此两个检查共用用户触发入口,但分别处理结果;只有已建立的强更状态可以抢占常规展示。
+
+**等待业务 API 返回版本错误。** Desktop 业务流量在本地,因此远程策略必须有独立游客请求。
+
+**使用可取消的常规消息框。** 它不负责持久下载进度、重试、页面兜底和策略刷新。专用模态窗口保留这些操作,同时不允许取消强更要求。
+
+**策略到达时终止任务。** 策略要求停止后续交互,而非正在运行的工作。安装批准仍是停止受影响任务的独立授权。
+
+**让策略选择或作废 updater 安装包。** 这会增加第二个产物权威,并违背已确认的高版本覆盖策略。只有 updater 选择和校验其版本绑定的安装包。
+
+**技术详情仅保留在日志中。** 弹窗内的折叠详情让用户无需查找本地日志即可排查停止失败,同时保持默认恢复提示可读。
+
+**对每个在途 API 请求都弹出警告。** 启动读取会在没有受影响任务时触发中断警告。将请求收尾与任务检查分开,可避免这种误报,也无需按 URL 分类 RPC 或丢弃未完成的写操作。
+
+**通过预加载传递本地化更新文案。** Desktop 壳 locale 可能不同于 Web 应用内选择的语言,已传递的字符串也无法响应之后的语言切换。语义化展示数据让组件继续拥有用户所选 Web 语言下的文案。
+
+**常规退出时保持产品窗口可见。** 先关闭自有 Host 会把预期断开展示到连接恢复界面。收尾前隐藏窗口既保留进程正常清理,也不会为主动关闭展示重试操作。
+
+## 后果
+
+常规检查和下载失败将语言字典提供的摘要与可展开的 updater 诊断分离。悬停提示不含原始错误文本;下载悬停提示说明操作和目标版本。Web 更新状态独立于原生弹窗语言,跟随应用内当前 locale。壳报告正在安装时,更新状态优先于连接反馈,因为停止自有后端是预期行为。安装失败会清除该优先级,使真实重连保持可见。常规退出会在发生同一预期后端断开前移除产品窗口。普通长诊断独立于确认操作滚动;展开详情不授予安装权限。
+
+[本地验证](../testing/2026-09-10-desktop-local-updater-qualification.zh.md)执行真实 Electron 模态窗口、受限预加载、页面按钮处理、网络策略和 updater 下载。主入口回归验证 Host 工作保留和业务控制拒绝。所属测试目录内的中文 DOM 预期输出约束可见文案和操作。这些测试不证明生产 API 兼容性、原生浏览器/剪贴板集成、完整工作区视觉效果、签名安装或重启后健康状态;[验证记录](../../../../apps/desktop/tests/README.zh.md)列出尚缺证据。
diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml
deleted file mode 100644
index 38e3528468..0000000000
--- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-12-session-unarchive-settings-page.md
-2026-09-12-session-unarchive-settings-page.md: 3b1e364e3abc6cea0a8b29ad78abb0ff7c274dbe
-2026-09-12-session-unarchive-settings-page.zh.md: 61e108dd50e61b9e6e9f72098973c067e3257407
diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md
deleted file mode 100644
index 3b1e364e3a..0000000000
--- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Agent Note: Session unarchive Settings page
-
-Status: implemented
-
-English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
-
-## Problem
-
-Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
-
-## Decision
-
-`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
-
-Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state.
-
-The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
-
-Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
-
-The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
-
-## Alternatives considered
-
-**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
-
-**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
-
-**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
-
-**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
-
-## Consequences
-
-The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work).
-
-The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
-
-## Testing
-
-`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
-
-## Related
-
-- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.
diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md
deleted file mode 100644
index 61e108dd50..0000000000
--- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Agent Note: Session unarchive Settings page
-
-Status: implemented
-
-[English](2026-09-12-session-unarchive-settings-page.md) | 中文
-
-## 问题
-
-归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
-
-## 决策
-
-`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
-
-取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。
-
-新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
-
-恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
-
-`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
-
-## 考虑过的替代方案
-
-**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
-
-**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
-
-**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
-
-**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
-
-## 后果
-
-归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。
-
-恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
-
-## 测试
-
-`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
-
-## 相关
-
-- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。
diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
index d01b653e42..d15870537e 100644
--- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
-2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5
-2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343
+2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90
+2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a
diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
index b6b2f249a1..807c79d06c 100644
--- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
+++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
@@ -12,15 +12,17 @@ The desktop app drew the stock macOS titlebar: an opaque bar above the web UI th
The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur.
-Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before.
+Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). These rules do not apply to plain Web or other desktop platforms. The [Windows caption decision](2026-09-16-windows-desktop-titlebar.md) owns its separate presentation.
**Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason.
**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
-**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen.
+**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md).
-**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
+**Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px.
+
+**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays.
**Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth.
@@ -40,10 +42,10 @@ Every web-side adjustment keys off `html[data-platform]`, which only the desktop
- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
-- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
-- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
+- The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)).
+- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet.
- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
## Testing
-ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
+ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet.
diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
index 8d4a4d17bd..a521aff762 100644
--- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
@@ -12,15 +12,17 @@ Status: implemented
Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。
-所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致。
+所有 macOS Web 侧调整均以 `html[data-platform='darwin']` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。这些规则不适用于纯 Web 或其他桌面平台。[Windows 顶栏决策](2026-09-16-windows-desktop-titlebar.zh.md)负责其独立呈现。
**透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。
**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
-**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
+**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。
-**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
+**窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。
+
+**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。
**全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。
@@ -40,10 +42,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
-- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
-- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
+- 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。
+- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。
- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
## Testing
-ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
+ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。
diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
index c5ffd3e8e7..50233385bf 100644
--- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
-2026-09-14-desktop-primary-runtime.md: d4dfd5fae400737de5ae852c05e2b6f117dc78f1
-2026-09-14-desktop-primary-runtime.zh.md: e64b6a53d6270f922e477fd844d611f8249581a4
+2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8
+2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4
diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
index d4dfd5fae4..51bbaac89c 100644
--- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
+++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
@@ -18,6 +18,8 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra
macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
+Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion.
+
Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
## Alternatives considered
@@ -28,6 +30,12 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe
**Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable.
+**Multi-file SignTool requests.** A software-certificate probe with a missing first file still signs the second file before returning an error. One process cannot guarantee stopping hardware operations at the first failure; token signing remains one file per supervised request.
+
+**Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution.
+
+Dependency manifests use electron-builder's own cleanup before inventory sealing. Its archive writer otherwise changes package metadata after signing; pre-applying that transformation keeps the final archive verifiable without replacing the sealed inventory after packaging.
+
## Consequences
The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions.
diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
index e64b6a53d6..4f27d61b5e 100644
--- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
@@ -18,6 +18,8 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site
macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
+Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。
+
Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
## Alternatives considered
@@ -28,6 +30,12 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0
**独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。
+**多文件 SignTool 请求。** 软件证书探针在第一个文件不存在时,仍会先给第二个文件签名,再返回错误。单个进程无法保证首个失败后停止硬件操作;令牌签名仍按每个受监督请求处理一个文件。
+
+**仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。
+
+依赖清单在记录完整性清单前使用 electron-builder 自身的清理逻辑。否则其归档写入器会在签名后修改包元数据;提前应用该转换可让最终归档通过校验,无需在打包后替换已封装的完整性清单。
+
## Consequences
应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。
diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml
index f27ca4a2b6..be51075feb 100644
--- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md
-2026-09-15-continuable-activation-capacity.md: bf7ca6d78d7a39a9c5d42dbc000de98706863cb5
-2026-09-15-continuable-activation-capacity.zh.md: 404c2c0f4eef9576670b51cd253b53ad0d4ef0d5
+2026-09-15-continuable-activation-capacity.md: a6403d10fd4bcfb73147a0482293429e108939e2
+2026-09-15-continuable-activation-capacity.zh.md: 75cf6ca7a352d503cfffef29416c0990f98185db
diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md
index bf7ca6d78d..a6403d10fd 100644
--- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md
+++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md
@@ -16,7 +16,7 @@ The Activation registry reserves a unique slot before fresh or cold-resume recon
Pool lookup, admission and release take amortized constant time. A weak root map does not retain dead root Agents; each pool holds only occupied tokens. No Session catalog scan, tree traversal, durable counter, or public capacity-query API is added.
-The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown.
+The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. The GUI stages both numbers and resets each to composition through the existing revision-fenced settings path. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md
index 404c2c0f4e..75cf6ca7a3 100644
--- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md
@@ -16,7 +16,7 @@ Activation registry 在新建或冷恢复重建首次让出执行前预占唯一
池查找、接纳和释放的摊还时间复杂度均为常数。根代理的弱引用映射不会保留已结束的根 Agent;每个池只持有已占用的 token。不增加 Session 目录扫描、树遍历、持久计数器或公开的容量查询 API。
-Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。
+Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。GUI 暂存两个数值,并通过现有的 revision 校验设置路径分别恢复组合默认值。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml
new file mode 100644
index 0000000000..f6c0f88653
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.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-15-docs-platform-command-tabs.md
+2026-09-15-docs-platform-command-tabs.md: 4d44d1191a7ffc1601e15aa4d9f119a90e06b0dd
+2026-09-15-docs-platform-command-tabs.zh.md: 450765b52d1507fa9b9e6a20a1854e6861655838
diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md
new file mode 100644
index 0000000000..4d44d1191a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md
@@ -0,0 +1,31 @@
+# Agent Note: Platform command tabs in the Python SDK guide
+
+Status: implemented
+
+English | [中文](2026-09-15-docs-platform-command-tabs.zh.md)
+
+## Problem
+
+The [Python SDK guide](../../../../docs/user/guide/python-sdk.md) gives different commands for POSIX shells and Windows PowerShell. Showing both versions in sequence makes readers skip an alternative at each step and can make alternative commands look cumulative.
+
+## Decision
+
+Each of the four platform-dependent steps uses VitePress's native `code-group`: Linux/macOS first, Windows PowerShell second. Each group selects independently and starts on Linux/macOS. Credential setup and running the example remain separate sequential steps. Shared Python and YAML examples stay directly visible.
+
+Both languages retain every command in canonical Markdown. The documentation projector preserves the groups and their code in the website's raw Markdown. Explicit anchors before each group preserve the former platform heading targets; following a platform anchor locates the group without selecting a tab.
+
+The theme adds a visible keyboard focus outline to native radio labels and displays both command blocks in search excerpts, whose tab controls are unavailable. A Markdown renderer adapter makes each native tab strip a form with submission prevented. Local search copies a section's rendered HTML, including radio names; separate form owners stop those copies from clearing the page's selection. VitePress owns selection, syntax highlighting, and copying the active code block. MPA builds omit those client handlers, so build-only styles display both command blocks and hide the tab strip and copy buttons. There is no shared selection state or stored preference.
+
+## Alternatives considered
+
+**Keep vertically repeated platform sections.** Both versions are immediately visible, but the repeated alternatives interrupt the guide's ordered steps.
+
+**Build a custom tab component with shared or persistent selection.** A Windows reader could select once, but shared state couples independent examples and introduces selection and lifecycle code beyond the required presentation. Native groups keep the interaction local.
+
+## Consequences
+
+SPA readers see one command version per step while MPA, repository, and raw-Markdown readers retain both. Windows readers select PowerShell in each group. Existing platform links still reach the corresponding step, although the platform names leave the page outline.
+
+Verification covers command preservation, bilingual pairing, projected and raw Markdown, and actual browser switching, copying, keyboard focus, search, themes, and narrow layouts. Framework-owned interaction is exercised on the running site; session replay is inapplicable to this documentation-only presentation.
+
+The focused renderer regression checks radio ownership when local search duplicates a section. The adapter depends on VitePress's native tab-strip markup and fails the build if that markup changes; a framework upgrade must retain the search isolation and browser checks.
diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md
new file mode 100644
index 0000000000..450765b52d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: Python SDK 指南的平台命令标签
+
+Status: implemented
+
+[English](2026-09-15-docs-platform-command-tabs.md) | 中文
+
+## 问题
+
+[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md)分别提供 POSIX shell 和 Windows PowerShell 命令。连续显示两个版本使读者在每一步都要跳过另一平台,也容易把二选一的命令理解为需要依次执行。
+
+## 决策
+
+四个平台相关步骤分别使用 VitePress 原生 `code-group`:Linux/macOS 在前,Windows PowerShell 在后。各组独立选择,初始均显示 Linux/macOS。配置凭据和运行示例仍是两个顺序步骤。共用的 Python 和 YAML 示例保持直接可见。
+
+两种语言均在规范 Markdown 中保留每条命令。文档投影器在网站原始 Markdown 中保留分组及代码。各组前的显式锚点保留原有平台标题的链接目标;平台锚点定位到分组,但不选择标签。
+
+主题为原生单选按钮标签补充可见的键盘焦点轮廓,并在无法操作标签的搜索摘要中显示两个命令块。Markdown 渲染适配器将各原生标签栏设为禁止提交的表单。本地搜索会复制章节渲染后的 HTML,其中包含单选按钮名称;独立的所属表单可避免副本清除页面的选择。VitePress 负责选择、语法高亮和复制当前代码块。MPA 构建不包含这些客户端处理器,因此构建专用样式显示两个命令块,并隐藏标签栏与复制按钮。不引入共享选择状态或持久化偏好。
+
+## 考虑过的替代方案
+
+**保留纵向重复的平台章节。** 两个版本都立即可见,但重复的备选命令会打断指南的顺序步骤。
+
+**自建标签组件并共享或持久化选择。** Windows 读者只需选择一次,但共享状态会耦合独立示例,并引入超出所需展示功能的选择与生命周期代码。原生分组将交互限定在各组内。
+
+## 影响
+
+SPA 读者每一步只看到一个命令版本,MPA、仓库和原始 Markdown 读者仍能获取两个版本。Windows 读者需要在各组中选择 PowerShell。原有平台链接仍到达对应步骤,但平台名称不再出现在页面大纲中。
+
+验证覆盖命令保留、双语配对、投影和原始 Markdown,以及真实浏览器中的切换、复制、键盘焦点、搜索、主题和窄屏布局。框架负责的交互在运行中的站点验收;会话回放不适用于这项纯文档展示变更。
+
+聚焦的渲染回归验证本地搜索复制章节时的单选按钮归属。适配器依赖 VitePress 原生标签栏的 HTML,结构变化时会使构建失败;框架升级必须保留搜索隔离和浏览器检查。
diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml
new file mode 100644
index 0000000000..182ff386d9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.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-15-markdown-file-preview-links.md
+2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b
+2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3
diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
new file mode 100644
index 0000000000..d9d535afc5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
@@ -0,0 +1,33 @@
+# Agent Note: Markdown file preview links
+
+Status: implemented
+
+English | [中文](2026-09-15-markdown-file-preview-links.zh.md)
+
+## Problem
+
+Assistant explanations link to existing source files that the turn does not modify or deliver. Restricting clickable references to produced-file mentions prevents readers from opening those sources beside the answer.
+
+## Decision
+
+Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path.
+
+The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert.
+
+The Web file-reference prompt asks for a link on every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Names default to a basename or clear alias with minimal disambiguating parents. Precise labels use `filename:24` or `filename:24–30`, while destinations keep the parser's `#L24` or `#L24-L30` syntax. The renderer preserves model-authored text; it does not rewrite labels to enforce the prompt.
+
+## Alternatives considered
+
+**Relative browser anchors.** They navigate the application URL instead of requesting Session file content.
+
+**Require a produced-file entry.** This excludes ordinary read-only explanations.
+
+**Add a preview service.** Chat already supplies the required opener and line parameter.
+
+**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes.
+
+**Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation.
+
+## Consequences
+
+Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar.
diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md
new file mode 100644
index 0000000000..63c18e284b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Markdown 文件预览链接
+
+Status: implemented
+
+[English](2026-09-15-markdown-file-preview-links.md) | 中文
+
+## Problem
+
+Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若仅允许产出文件提及成为可点击引用,读者就无法在回答旁打开这些源码。
+
+## Decision
+
+落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。
+
+现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。
+
+Web 文件引用提示词要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。名称默认使用 basename 或清楚的别名,仅添加消除歧义所需的父目录。精确标签使用 `filename:24` 或 `filename:24–30`,目标仍使用解析器支持的 `#L24` 或 `#L24-L30` 语法。渲染器保留模型原文,不通过重写标签强制满足提示词。
+
+## Alternatives considered
+
+**相对浏览器链接。** 这会跳转应用 URL,而非请求 Session 文件内容。
+
+**要求产出文件条目。** 这会排除普通只读讲解。
+
+**增加预览服务。** Chat 已提供所需打开器和行号参数。
+
+**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。
+
+**文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。
+
+## Consequences
+
+源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。
diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
deleted file mode 100644
index 9d029af06f..0000000000
--- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Agent Note: Known-site link marks
-
-Status: implemented
-
-English | [中文](2026-09-16-known-site-link-marks.zh.md)
-
-## Problem
-
-Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category.
-
-## Decision
-
-`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
-
-`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
-
-The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
-
-Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork.
-
-Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination.
-
-## Alternatives considered
-
-- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback.
-- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color.
-- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path.
-- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know.
-- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials.
-
-## Consequences
-
-- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec.
-- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression.
-- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set.
-- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe.
-- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject.
-- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks.
diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md
deleted file mode 100644
index 2c7630d122..0000000000
--- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Agent Note: 已知站点链接标记
-
-Status: implemented
-
-[English](2026-09-16-known-site-link-marks.md) | 中文
-
-## Problem
-
-转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。
-
-## Decision
-
-`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
-
-ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
-
-图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
-
-对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。
-
-两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
-
-## Alternatives considered
-
-- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
-- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
-- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。
-- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
-- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
-
-## Consequences
-
-- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。
-- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。
-- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。
-- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
-- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
-- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。
diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml
new file mode 100644
index 0000000000..7614cd0318
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.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-16-open-in-default-app-for-sidebar-files.md
+2026-09-16-open-in-default-app-for-sidebar-files.md: fa7506be77e8a62e5dfd97b59de6ebc4be510ae0
+2026-09-16-open-in-default-app-for-sidebar-files.zh.md: af124182dc1b38658ff55736277e499e963a6281
diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md
new file mode 100644
index 0000000000..fa7506be77
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md
@@ -0,0 +1,29 @@
+# Agent Note: Open Sidebar-previewed files in the default application
+
+Status: implemented
+
+English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md)
+
+## Problem
+
+Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration.
+
+## Decision
+
+[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the file’s absolute execution-environment path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot.
+
+ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control.
+
+## Alternatives considered
+
+**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the shared Session Remote owns verification of the Host path through the composed filesystem.
+
+**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic.
+
+**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration.
+
+## Consequences
+
+Every previewed file with a verified Host mapping can be opened locally from the Sidebar, including files that were never delivered. Paths without that mapping are refused before native opening. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted.
+
+Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control.
diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md
new file mode 100644
index 0000000000..af124182dc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md
@@ -0,0 +1,29 @@
+# Agent Note:用默认应用打开 Sidebar 预览的文件
+
+Status: implemented
+
+[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文
+
+## 问题
+
+用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。
+
+## 决定
+
+[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带文件在执行环境中的绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。
+
+ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。
+
+## 考虑过的备选
+
+**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;共享的 Session Remote 负责通过当前文件系统验证 Host 路径。
+
+**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。
+
+**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。
+
+## 后果
+
+每个有经过验证的 Host 映射的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。没有这种映射的路径会在原生打开前被拒绝。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。
+
+单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml
similarity index 56%
rename from .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml
rename to .agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml
index 4ac53a23d4..2da6cd2bfa 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml
@@ -1,6 +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-15-desktop-profile-core-cleanup.md
-2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
-2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md
+2026-09-16-performance-usage-preference.md: 36ab30561fccc6c824ee8483f5a533a9178f11c5
+2026-09-16-performance-usage-preference.zh.md: ecfd19c41c861116d18829b355bf8c8df4ea929a
diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md
new file mode 100644
index 0000000000..36ab30561f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md
@@ -0,0 +1,31 @@
+# Agent Note: Performance and usage detail preference
+
+Status: implemented
+
+English | [中文](2026-09-16-performance-usage-preference.zh.md)
+
+## Problem
+
+Always-visible counts and accounting controls add density to Chat even when readers only need output speed and cache effectiveness.
+
+## Decision
+
+Chat owns `ui-chat.performanceUsage` in its existing settings namespace. General Settings offers Compact and Detailed, with Detailed as the default to preserve access to accounting. Both the composer and turn-tail renderer consume the same accepted settings source through framework-bound hooks. Host settings own persistence, validation, and failed-write recovery.
+
+Compact retains only available output speed and cache-hit percentage under the composer. These readings are plain text with no statistics dialog. Detailed retains session statistic dialogs and per-Turn usage. Neither mode renders the elapsed-time action in the completed-turn footer. This preference changes presentation, never telemetry collection, accounting, or Session events.
+
+The [composer statistics decision](2026-09-07-composer-session-stats-pills.md) remains authoritative for accounting sources, dialog exclusivity, and composer spacing in Detailed mode.
+
+The requested footer design prioritizes message actions and token accounting in both modes. Completed turns therefore expose no per-turn elapsed-time control; Detailed restores accounting detail, not that control. The running clock and session-wide timing statistics remain independent of this choice.
+
+## Alternatives considered
+
+**Default to Compact.** This would hide accounting controls for existing users without an explicit choice. The request defines both modes but no default, so Detailed preserves their access.
+
+**Filter telemetry when Compact is selected.** This would discard information needed after switching to Detailed or reopening history. A display preference must not change recorded data.
+
+## Consequences
+
+Unavailable speed or cache-hit data is omitted rather than replaced with invented values. The settings scope provides one accepted value to all consumers without duplicating subscription state. Component coverage checks mode-specific visibility; the [recorded-session browser scenario](../../../../apps/web/tests/message-actions.e2e.ts) exercises selection, reload persistence, and Compact output.
+
+On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md
new file mode 100644
index 0000000000..ecfd19c41c
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md
@@ -0,0 +1,31 @@
+# Agent Note:性能与用量详细程度偏好
+
+Status: implemented
+
+[English](2026-09-16-performance-usage-preference.md) | 中文
+
+## Problem
+
+当读者只需要输出速度和缓存效果时,始终显示的计数和记账控件会增加聊天界面的密度。
+
+## Decision
+
+Chat 在既有设置命名空间中拥有 `ui-chat.performanceUsage`。通用设置提供简洁和详细选项,默认详细以保留记账入口。输入框和轮次页脚渲染器通过框架绑定的 Hook 消费同一个已接受设置源。Host 设置负责持久化、校验和写入失败恢复。
+
+简洁模式仅在输入框下方保留可用的输出速度和缓存命中率。这些读数为纯文本,不提供统计卡片。详细模式保留会话统计卡片和每轮用量。两种模式均不渲染已完成轮次页脚中的耗时操作。该偏好只改变展示,不改变遥测收集、记账或 Session 事件。
+
+[输入框统计决策](2026-09-07-composer-session-stats-pills.zh.md)仍负责详细模式中的记账来源、卡片互斥和输入框间距。
+
+所要求的页脚设计在两种模式下均优先展示消息操作和 token 记账。因此,已完成轮次不提供单轮耗时控件;详细模式恢复记账详情,不恢复该控件。运行中的时钟和会话级计时统计独立于此选择。
+
+## Alternatives considered
+
+**默认简洁。** 这会在已有用户未明确选择时隐藏记账控件。需求定义了两种模式但未指定默认值,因此详细模式保留既有入口。
+
+**选择简洁时过滤遥测。** 这会丢弃切换回详细模式或重新打开历史时需要的信息。展示偏好不能改变记录数据。
+
+## Consequences
+
+不可用的速度或缓存命中数据会被省略,不填入虚构值。设置作用域为所有消费者提供同一个已接受值,无需复制订阅状态。组件覆盖检查不同模式的可见性;[录制会话浏览器场景](../../../../apps/web/tests/message-actions.e2e.ts)验证选择、刷新持久化和简洁模式输出。
+
+在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml
new file mode 100644
index 0000000000..e98c3c4e39
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.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-16-shared-subagent-settings-card.md
+2026-09-16-shared-subagent-settings-card.md: d9457b3e9b9786d77dcced4cac8ca1086914699a
+2026-09-16-shared-subagent-settings-card.zh.md: ccde0c97df24239b4e5113fecbaec6e85f19b145
diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md
new file mode 100644
index 0000000000..d9457b3e9b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md
@@ -0,0 +1,29 @@
+# Agent Note: Shared Subagent settings card
+
+Status: implemented
+
+English | [中文](2026-09-16-shared-subagent-settings-card.zh.md)
+
+## Problem
+
+Delegation limits and model authorization describe the same Subagent workflow, but separate settings cards make users locate and save them independently.
+
+## Decision
+
+One Subagent page groups delegation limits and model selection, with one save button. Leaving the page discards both drafts. Both existing controllers retain ownership of their drafts, validation and namespace writes. The card blocks saving if either draft is invalid or conflicted, disables both sections during saving, and stays open after both settle successfully. A failed section retains its draft for retry. Limit explanations stay behind information buttons beside the field labels, with concrete depth examples and counting rules. Validation errors remain visible so disclosure does not hide a blocked save.
+
+The plugin registers one `plugins.item` entry while either namespace is served. Its component renders only the available sections, preserving limits-only and model-only deployments without teaching the Plugins page about Subagent grouping.
+
+## Alternatives considered
+
+**Keep separate cards.** Independent controls obscure the relationship between delegation policy and model authorization and require separate save gestures.
+
+**Merge Host namespaces.** UI grouping needs no change to persisted settings, namespace revisions or the different times these policies take effect. The existing namespace controllers preserve those contracts.
+
+## Consequences
+
+The user reviews both sections in one place. A save remains separate namespace writes, so partial success is possible; successful drafts clear while rejected drafts remain visible and retryable. Model authorization still writes its switch and routes atomically within its own namespace. The [package reference](../../../../packages/client/ui-settings-plugins/README.md) describes the controls and their applicability.
+
+## Verification
+
+Focused component and controller tests cover shared validation, save, discard on leaving, pending writes, partial failure and deployments serving either namespace. The assembled Web scenario saves both sections through one button and reads back the settings document.
diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md
new file mode 100644
index 0000000000..ccde0c97df
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 统一 Subagent 设置卡片
+
+Status: implemented
+
+[English](2026-09-16-shared-subagent-settings-card.md) | 中文
+
+## Problem
+
+委派限制与模型授权描述的是同一套 Subagent 工作流程,但分开的设置卡片让用户必须分别查找和保存。
+
+## Decision
+
+一个 Subagent 页面组织委派限制与模型选择,共用一个保存按钮。离开页面会丢弃两个草稿。两个现有控制器仍各自拥有草稿、校验和命名空间写入。任一草稿无效或冲突时,卡片禁止保存;保存期间禁用两个分区;两者均成功完成后仍保持打开。失败的分区保留草稿以供重试。限制说明通过字段名旁的信息按钮按需展开,使用具体层级示例和计数规则。校验错误仍直接显示,避免折叠说明后隐藏阻止保存的原因。
+
+插件在任一命名空间可用时注册一个 `plugins.item` 条目。组件仅呈现可用的分区,既保留只提供限制或模型设置的部署,也无需让插件页了解 Subagent 分组。
+
+## Alternatives considered
+
+**保留分开的卡片。** 独立控件使委派策略与模型授权之间的关系不够清晰,也需要分别保存。
+
+**合并 Host 命名空间。** UI 分组不需要改变持久化设置、命名空间 revision 或两项策略不同的生效时机。现有命名空间控制器保留这些约定。
+
+## Consequences
+
+用户可以在同一处检查两个分区。保存仍分别写入各自的命名空间,因此可能部分成功;成功的草稿清除,被拒绝的草稿保持可见并可重试。模型授权仍在自己的命名空间内原子写入开关与路由。[包参考文档](../../../../packages/client/ui-settings-plugins/README.zh.md) 描述控件及其生效范围。
+
+## Verification
+
+聚焦的组件和控制器测试覆盖共同校验、保存、离开时丢弃草稿、进行中的写入、部分失败,以及仅提供任一命名空间的部署。组装后的 Web 场景通过一个按钮保存两个分区,并回读设置文档。
diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml
new file mode 100644
index 0000000000..b261e98e5b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.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-16-sidebar-browser.md
+2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c
+2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e
diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
new file mode 100644
index 0000000000..3fa4e1dbb7
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
@@ -0,0 +1,74 @@
+# Agent Note: Sandboxed Sidebar browser
+
+Status: implemented
+
+English | [中文](2026-09-16-sidebar-browser.zh.md)
+
+## Problem
+
+The right Sidebar can preview addressed workspace files, but it has no independent surface for visiting a Web page. Opening a page outside the application loses the Sidebar's split, float, and tab lifecycle. Treating an arbitrary page like Document Preview content also obscures a different trust model: a Web page controls a live browsing context, while a document renderer receives bytes selected for one preview.
+
+A parent page cannot inspect or drive a cross-origin iframe's internal history. The supported behavior must not imply that capability.
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values.
+
+`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters when that type is registered, or uses the system browser otherwise; the Markdown renderer does not import the Browser feature.
+
+The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface.
+
+The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
+
+Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component.
+
+`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
+
+Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities.
+
+## Web navigation states
+
+The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored.
+
+| State | Entry | Address and controls |
+|---|---|---|
+| `empty` | The tab has no controlled target. | The address is empty. Back, Forward, Reload, and external-open are disabled. |
+| `loading` | Address submission, an application-history move, or Reload starts a new revision. | The requested URL remains authoritative. Back and Forward follow application-history bounds; Reload remains available; external-open follows the known target protocol. |
+| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. |
+| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. |
+
+Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
+
+## Deferred Electron carrier
+
+Electron `` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`.
+
+The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins.
+
+The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied.
+
+The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization.
+
+Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target.
+
+## Alternatives considered
+
+**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning.
+
+**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support.
+
+**Support `file:` URLs in Browser.** Rejected because local files already belong to Document Preview, while browser navigation has a different trust model. Browser refuses the protocol instead of acquiring filesystem or Workspace Files access.
+
+**Proxy Web pages through the Host.** Rejected because a compatible proxy would have to rewrite URLs, CSP, cookies, modules, streams, forms, and downloads while turning the Host into a general outbound requester.
+
+**Implement the Electron carrier in the initial Browser change.** Deferred so the first implementation does not enable a new Electron guest surface without packaged-app evidence for overlay stacking, target lifetime, cookie isolation, and every permission denial.
+
+## Verification
+
+Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal.
+
+## Consequences
+
+The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior.
+
+Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md
new file mode 100644
index 0000000000..69d78dbf2b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md
@@ -0,0 +1,74 @@
+# Agent Note: 沙箱化 Sidebar 浏览器
+
+Status: implemented
+
+[English](2026-09-16-sidebar-browser.md) | 中文
+
+## Problem
+
+右侧 Sidebar 可以预览已寻址的工作区文件,但没有用于访问网页的独立界面。在应用外打开会丢失 Sidebar 的分栏、浮动与 tab 生命周期。把任意网页当成 Document Preview 内容,也会混淆两种不同的信任模型:网页控制一个活跃浏览上下文,而文档 renderer 只接收为一次预览选定的字节。
+
+父页面不能检查或驱动跨域 iframe 的内部 history。支持的行为不能暗示具备这种能力。
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。
+
+`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider;已注册该类型时,它以 URL 作为 typed navigation 参数打开新的 `browser` tab,否则使用系统浏览器;Markdown renderer 不导入 Browser feature。
+
+地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。
+
+当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
+
+每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。
+
+`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
+
+Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。
+
+## Web 导航状态
+
+对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。
+
+| 状态 | 进入条件 | 地址与控件 |
+|---|---|---|
+| `empty` | tab 没有受控目标。 | 地址为空;后退、前进、刷新与外部打开均禁用。 |
+| `loading` | 地址提交、应用 history 移动或刷新启动新的 revision。 | 请求 URL 仍是权威地址;后退和前进遵循应用 history 范围;刷新保持可用;外部打开遵循已知目标的协议。 |
+| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 |
+| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 |
+
+每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
+
+## Deferred Electron carrier
+
+Electron `` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。
+
+Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。
+
+主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。
+
+view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。
+
+每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。
+
+## Alternatives considered
+
+**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。
+
+**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。
+
+**在 Browser 中支持 `file:` URL。** 不采用,因为本地文件已由 Document Preview 负责,而 browser 导航使用不同的信任模型。Browser 直接拒绝该协议,不获取文件系统或 Workspace Files 能力。
+
+**通过 Host 代理网页。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。
+
+**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。
+
+## Verification
+
+单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
+
+## Consequences
+
+Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
+
+站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml
new file mode 100644
index 0000000000..d85ac2b2b2
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.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-16-windows-desktop-titlebar.md
+2026-09-16-windows-desktop-titlebar.md: 17fd2aac308cff0b4357a0c39c3fe757cfc60f5b
+2026-09-16-windows-desktop-titlebar.zh.md: 05903d86960942204d7b18d4155266c144effc1a
diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
new file mode 100644
index 0000000000..17fd2aac30
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
@@ -0,0 +1,29 @@
+# Agent Note: Windows desktop caption and application menus
+
+Status: implemented
+
+English | [中文](2026-09-16-windows-desktop-titlebar.zh.md)
+
+## Problem
+
+Windows needs a compact caption that keeps sidebar navigation available when the sidebar is hidden or files fill the content area. A separate Application/Edit menu consumes another row and can show a different language from the application. The shared client must preserve ordinary Web and macOS presentation.
+
+## Decision
+
+The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame.
+
+Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
+
+## Alternatives considered
+
+An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and editing commands. Plugin management uses the main application’s Plugins page. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
+
+Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window.
+
+## Consequences
+
+The caption remains available above fullscreen file panels, and collapsed navigation consumes no vertical rail. Caption menus preserve application and editing commands while ordinary Web documents receive no desktop controls. Editing actions send the corresponding keys to the focused editor so its own undo history applies. Caption entries stay above content modal overlays. Keyboard activation restores the last editor and selection before dispatch; pointer activation preserves editor focus; menu completion clears the active entry. Main-process validation rejects foreign frames, unknown menu names, and invalid anchor coordinates. Native popup appearance follows the operating system.
+
+## Testing
+
+Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the absence of the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.
diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md
new file mode 100644
index 0000000000..05903d8696
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Windows 桌面顶栏与应用菜单
+
+Status: implemented
+
+[English](2026-09-16-windows-desktop-titlebar.md) | 中文
+
+## Problem
+
+Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保留侧栏导航。独立的应用/编辑菜单多占一行,且可能与应用语言不同。共享客户端必须保留普通 Web 和 macOS 的呈现。
+
+## Decision
+
+Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。
+
+Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
+
+## Alternatives considered
+
+按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
+
+自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。
+
+## Consequences
+
+顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。顶栏入口位于内容弹窗遮罩之上。键盘激活在派发前恢复上一编辑器及选区;指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。
+
+## Testing
+
+桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。
diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml
new file mode 100644
index 0000000000..cf44de1cb6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.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-17-compact-translucent-menu-surfaces.md
+2026-09-17-compact-translucent-menu-surfaces.md: 08fcb2d851e4c75ce9069fc797e8dbcb070acc5b
+2026-09-17-compact-translucent-menu-surfaces.zh.md: 8e13634995d46b7414f17bbb51443df85cb3ac3c
diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md
new file mode 100644
index 0000000000..08fcb2d851
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md
@@ -0,0 +1,29 @@
+# Agent Note: Compact translucent menu surfaces
+
+Status: implemented
+
+English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md)
+
+## Problem
+
+Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages.
+
+## Decision
+
+`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. A surface containing fixed-position overlays paints the fill and filter on an isolated background pseudo-element, because a filtered ancestor would otherwise change those overlays' containing block. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill.
+
+The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line.
+
+The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings.
+
+## Alternatives considered
+
+**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`.
+
+**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family.
+
+**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation.
+
+## Consequences
+
+Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule, either on the surface or on its isolated background pseudo-element; the stylesheet gate rejects a material layer that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing.
diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md
new file mode 100644
index 0000000000..8e13634995
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 紧凑半透明菜单表面
+
+Status: implemented
+
+[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文
+
+## Problem
+
+共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。
+
+## Decision
+
+`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。包含 fixed 定位浮层的表面把填充与滤镜绘制在隔离的背景伪元素上,因为带滤镜的祖先会改变这些浮层的包含块。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。
+
+共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。
+
+WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。
+
+## Alternatives considered
+
+**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。
+
+**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。
+
+**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。
+
+## Consequences
+
+高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token,规则可以属于表面本身,也可以属于其隔离的背景伪元素;样式表门禁会拒绝漏掉 filter 的材质层。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。
diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml
new file mode 100644
index 0000000000..69db5487b8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.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-17-developer-tools-settings.md
+2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2
+2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c
diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
new file mode 100644
index 0000000000..e76cb7f367
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
@@ -0,0 +1,29 @@
+# Agent Note: Shared developer-tool settings
+
+Status: implemented
+
+English | [中文](2026-09-17-developer-tools-settings.zh.md)
+
+## Problem
+
+Diagnostic views, preset selection and changed-file summaries add complexity to routine tasks. Scripted HTML also grants capabilities that a basic document preview does not need. Desktop and Web share these renderers, so independent switches would let the same setting produce different results across clients.
+
+## Decision
+
+The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
+
+Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
+
+Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
+
+## Alternatives considered
+
+**Limit the switch to desktop.** The selected requirement contains no desktop-only restriction, and the affected UI is shared. A desktop check would leave the Web renderer with inconsistent visibility and preview behavior.
+
+**Hide controls without changing the active View or iframe.** Previously selected diagnostic content and already-running scripts would survive the toggle. Filtering the available View roster and remounting the HTML context enforce the selected presentation immediately.
+
+## Consequences
+
+Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
+
+Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md
new file mode 100644
index 0000000000..5a7d6d59db
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 共享开发者工具设置
+
+Status: implemented
+
+[English](2026-09-17-developer-tools-settings.md) | 中文
+
+## Problem
+
+诊断视图、预设选择和改动文件摘要增加了日常任务的复杂度。支持脚本的 HTML 还授予了基础文档预览不需要的能力。桌面端和 Web 共享这些渲染器,分别设置开关会让同一设置在不同客户端产生不同结果。
+
+## Decision
+
+设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
+
+关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
+
+插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
+
+## Alternatives considered
+
+**仅在桌面端提供开关。** 所选需求没有仅限桌面的约束,相关 UI 也是共享的。桌面端条件判断会让 Web 渲染器的展示和预览行为不一致。
+
+**隐藏控件但不改变激活的 View 或 iframe。** 之前选择的诊断内容和已经运行的脚本会在切换后继续存在。过滤可用 View 并重新挂载 HTML 上下文,可以立即执行所选展示策略。
+
+## Consequences
+
+基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
+
+组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml
new file mode 100644
index 0000000000..da8a49ddb6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.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-17-persistent-plan-cards.md
+2026-09-17-persistent-plan-cards.md: 096c89c7ed3e52293787a2724b0c7231ae3740ae
+2026-09-17-persistent-plan-cards.zh.md: 95cce3fed84dd802195b87f593654b7d511933cb
diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
new file mode 100644
index 0000000000..096c89c7ed
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
@@ -0,0 +1,31 @@
+# Agent Note: Persistent plan cards and sidebar reading
+
+Status: implemented
+
+English | [中文](2026-09-17-persistent-plan-cards.zh.md)
+
+## Problem
+
+A plan review occupies the composer only until the user answers or dismisses it. A completed Turn also folds its tool calls into the process disclosure. Neither lifetime gives a submitted plan a discoverable place to read while implementation continues.
+
+## Decision
+
+`ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans.
+
+The review intent may carry the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. When an invocation exists, both open the resource identified by its complete Session address and invocation. Subagent addresses retain the direct parent, child, and mode for snapshot reads, pagination, and reload. The resource address selects the document’s Session, while the mounted Sidebar selects where its preview opens; embedded child conversations do not require a child Sidebar store. Sidebar layout retains that address; the resource provider reads the existing Session history, paging backwards when the invocation is older than the opening window. Its temporary follow closes after the opening snapshot.
+
+A review without an invocation opens a temporary address keyed by browser lifetime, Session, and pending request. The sidebar’s existing navigation parameters hold its Markdown without adding another document store or persisting text in layout. Closing and reopening from the pending card supplies the text again; restored tabs without navigation parameters report an expired preview.
+
+The document is read-only. The pending review shows its status, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation or unlogged request, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. Approval stays in the pending-question owner; reopening a historical document does not revive a settled review or authorize implementation. Different submissions remain distinct even when they share a heading.
+
+## Alternatives considered
+
+**Keep only the pending review or an expandable tool row.** Both hide the document behind unrelated interaction or process state. The user needs a persistent artifact entry.
+
+**Write a Markdown file or persist the document in sidebar layout.** The tool arguments already own the exact submitted text. Another durable copy would need synchronization and could disagree with the reviewed plan. The [plan-state decision](../simplification/2026-07-22-plan-specific-collaboration-state.md) remains the authority for that ownership.
+
+**Select a single owner for the Turn tail.** Plans and file deliveries can occur in the same Turn. An additive list lets both plugins contribute without either knowing the other’s data or rendering.
+
+## Consequences
+
+Logged plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures.
diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md
new file mode 100644
index 0000000000..95cce3fed8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 常驻计划卡片与侧边栏阅读
+
+Status: implemented
+
+[English](2026-09-17-persistent-plan-cards.md) | 中文
+
+## Problem
+
+计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。
+
+## Decision
+
+`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。
+
+审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。资源地址确定文档所属的会话,当前挂载的侧边栏决定预览打开的位置;嵌入式子对话不需要子会话自己的侧边栏 store。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
+
+没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。
+
+文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用或无调用标识的请求,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。
+
+## Alternatives considered
+
+**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。
+
+**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。
+
+**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染。
+
+## Consequences
+
+已记录计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。
diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml
new file mode 100644
index 0000000000..70ee32d07a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.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-17-sidebar-file-and-directory-auto-refresh.md
+2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2
+2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa
diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md
new file mode 100644
index 0000000000..f33be6a8d4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md
@@ -0,0 +1,345 @@
+# Agent Note: On-demand watching and automatic refresh for Sidebar previews and file trees
+
+Status: implemented
+
+English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)
+
+## Problem
+
+The Sidebar's Document Preview and Files panels need to reflect current disk state. Changes come from Harness file tools, shell commands, user editors, and other processes; observing only Harness file operations is insufficient.
+
+The starting implementation has both read paths but no complete automatic-refresh path.
+
+| Object | Starting behavior | Gap |
+|---|---|---|
+| File Resource | `ResourceProvider.open()` supplies initial metadata and later changes; `ResourceRegistry` owns subscriptions and retention; components read through `useResource` | File changes do not come from an OS watcher |
+| Document Preview | `TextPreview` compares Resource and content versions and provides reload for three loading modes | A change only prompts the user to reload |
+| Workspace file events | `WorkspaceFiles.changes()` forwards the current Session's `fs/observed`; the Client filters by file | The Host does not know which paths are followed; external editors and shell writes produce no such events |
+| Files panel | `filesFace` lists directories; `createFilesStore` retains loaded levels and expansion state | Creation, deletion, and renaming require manual refresh; reopening an expanded directory can show old cached entries |
+
+`Resource` holds file metadata, not preview contents. Document Preview or its selected renderer reads the contents. Metadata updates and content reloads therefore remain separate responsibilities: the generic Resource acquires no content cache, and renderers do not directly manage filesystem watchers.
+
+## Decision
+
+On-demand observation connects the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently.
+
+- Document Preview follows the current file. Its Resource publishes new metadata, and the preview invokes its existing reload. The HTML root and referenced CSS/JS files join one `ResourceGroup`; any member change invalidates the whole HTML preview.
+- Files manages reads and watchers through a directory-node tree. Each open node loads and watches only its direct entries. The Client loads and subscribes to exactly the subtree levels it opens, without traversing unopened descendants.
+- Collapsing a directory releases that directory's watch and those of its hidden descendants. Reopening subscribes and reads again; the old cache is not treated as current.
+- Watches carry only metadata or invalidation. Contents still use the existing file readers; directory contents still use `list()`.
+- Local Chokidar watches use OS events, without polling the whole Workspace or recursively scanning all descendants by default.
+- Both panels retain manual refresh and automatic-refresh functionality, but temporarily hide the automatic-refresh icon button. State and toggle logic remain; new Tabs default to enabled. Hiding the control does not stop observation or automatic refresh.
+
+Notifications invoke existing reload/list operations; a failed reload does not retain previous preview content. The following sections describe the implementation's responsibilities, data flow, and verification.
+
+## Implementation and responsibilities
+
+### FS and Host
+
+| Class or type | File | Responsibility |
+|---|---|---|
+| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code |
+| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` |
+| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency |
+| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks |
+| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar |
+| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority |
+| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch |
+| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure |
+| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | Reuse `ready`/`change` frames for a path-only request, without a request kind or separate directory frame format |
+
+### Client and Sidebar
+
+| Class, function, or type | File | Responsibility |
+|---|---|---|
+| `ChangeFeed`, `SessionFeed`, `Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | Key streams by Session and target path; reuse readiness, path binding, cancellation, and reconnect logic without reorganizing classes just to rename them |
+| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | Pass the address's Session and path to the target stream; publish complete metadata initially and after notifications, rather than replacing `version` while retaining stale `bytes` |
+| `ResourceRegistry`, `ResourceProvider`, `UseResource` | [Resource definitions](../../../../packages/client/resources/src/client/contract.ts), [ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | Keep existing streaming, retention counts, and subscriptions; add neither a second `onChange` nor a generic content-reload API |
+| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Invoke existing reload when a Resource or group changes and automatic refresh is enabled; retain errors and manual refresh, with the separate automatic-refresh button temporarily hidden |
+| `textFace`, `TabReads`, `createTextStore` | [Preview face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts), [Preview store](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | Own one group per Tab; add default-enabled `autoRefresh` and pending-refresh flag `resourcesDirty`; reuse content-read generations and `loadRevision` |
+| Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service |
+| `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing |
+| Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh |
+| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` |
+| Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences |
+| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController |
+| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads |
+| Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports |
+
+## Watch requests and notifications
+
+### One stream per explicit target
+
+Each request names one target path. The Host determines its current type through `stat`; the Client supplies no file/directory kind.
+
+```text
+changes(sessionId, path, signal)
+changes(scope, path, signal)
+```
+
+The Host method still receives resolved Session context through `WorkspaceFileScope`. The Client supplies a Session id; it cannot forge the Host's working directory or permissions.
+
+The Host checks Workspace containment whenever the current target is a directory, including when an absent or ordinary-file target becomes a directory. Ordinary files outside the Workspace remain readable and watchable through the selected FS.
+
+| Notification | Contents | Consumer action |
+|---|---|---|
+| Ready | This generation's target watch is established | Perform initial or reconnect `stat()`/`list()` and consume changes queued during the read |
+| File present or updated | Existing path and version notification; Client stats again | Update Resource path, version, and size from the complete result; reread contents when automatic refresh is enabled |
+| File absent | Target path and absence state | Resource reports unavailability; preview retains loaded content and shows the error |
+| Directory invalidated | `change` frame in that directory's target stream | Call only that directory's `list()` again, without deduplicating by directory version or refreshing the entire tree |
+| Watch failure | Initialization failure reports `workspace-file/watch-unsupported`; runtime errors end the watch | The Client handles failure where it consumes the watch; ordinary reads and manual refresh remain available, without reporting file deletion |
+
+Notifications need neither file contents, complete directory listings, nor per-change patches. Paths identify filenames; current stat supplies size. Modification time used only for freshness remains part of the backend version token, without UI token parsing or another timestamp comparison.
+
+Resource path, version, and size come from the same stat. Keep the existing small notification followed by Client stat instead of expanding the event protocol to save one request. Directory invalidation cannot depend only on directory mtime: a direct child's size or type can change `list()` output, so relevant child events invalidate that level.
+
+### OS watching and access scope
+
+- `FileSystem.watch()` accepts a target resolved by its provider; the FS provider owns the meaning of local or remote paths.
+- File watching retains file-read access rules. Existing previews may read some paths outside the Workspace through the selected FS; directory-tree containment must not be imposed on those files.
+- Directory watching retains `list()`'s Workspace scope and path checks, including after target type changes, without widening browsing authority.
+- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. The local provider watches that parent directly, filters entries and events to the target file, and waits for the parent watcher to be ready before reporting readiness, including when the file does not yet exist.
+- A directory watch observes itself and direct entries, without recursively opening unexpanded children. Creation, deletion, renaming, type changes, and visible metadata changes of direct entries invalidate that level.
+- Local watching uses OS events. Deployment controls such as write-stability time or event-coalescing windows, if needed, belong in their owner's configuration rather than scattered component constants.
+- OS watching drives UI refresh only. It does not fabricate an agent's authoritative `fs/observed` read observation or change the observed-version policy for editing.
+
+## Complete file-preview data flow
+
+```text
+Files.openResource / file.link
+ → Sidebar.openTab
+ → TabDomain → Resource.pin
+ → ResourceRegistry → createFileResourceProvider.open
+ → changes(sessionId, path)
+ → WorkspaceFiles → FsTarget
+ → WorkspaceChangeFeed → FileSystem.watch
+ → LocalFileSystem → Chokidar
+ → ready → Client.stat → ResourceSnapshot
+
+Editor / Shell / file tool → disk
+ → Chokidar → LocalFileSystem
+ → WorkspaceChangeFeed → FileSystem.stat
+ → Remote.change
+ → Client.stat → ResourceSnapshot
+ → ResourceGroup → TextPreview
+ → reloadPages / reloadAll / prepareRenderer
+ → DocumentContent / renderer revision
+ → renderer
+```
+
+Preview content reads may run in parallel with metadata initialization. ResourceGroup uses each member's first metadata as a baseline and forwards only later changes, without first-read version reconciliation.
+
+| Loading mode | Existing refresh entry | Result |
+|---|---|---|
+| `text-pages` | `reloadPages()` | Retire the old content generation and reread text pages without mixing versions |
+| `bytes-complete` | `reloadAll()` | Reread complete bytes for PDF, HTML, image, and other byte consumers |
+| `renderer` | `prepareRenderer(..., reload = true)` | Increment `loadRevision`; the renderer cancels its previous load and requests again. Office failures end loading through `failed()`, allowing later file changes to retry |
+
+Ordinary renderers need neither `watch()` nor a new reload interface. Changes to existing `DocumentContent` text, bytes, and revisions already drive reloading.
+
+## HTML ResourceGroup
+
+An HTML preview's group contains the HTML root and CSS/JS files actually read by the existing packer. Every member is an ordinary file Resource, with no separate dependency watcher or file type. The group is package-private and reuses Resource subscriptions and retention counts.
+
+```text
+index.html Resource ─┐
+theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
+page.js Resource ───┘
+```
+
+- `addResource(address)` joins a dependency after `readRelated` returns its Host-resolved `absolutePath`. Existing members do not resubscribe, and no content-read version is passed or retained.
+- `setResources(addresses)` commits the parsed dependency list. The preview owner always retains the root and releases other members absent from the new list.
+- CSS/JS references use the HTML's Session and Host path resolution. A failed read also retains a string `error.details.path` supplied by the Host; without that path, the Client does not guess one.
+- A CSS or JS change reloads the complete HTML and creates new iframe content even when the HTML source itself is unchanged.
+- Missing dependencies with a Host-reported path remain members. Parsing failures retain already discovered dependencies so their later changes can trigger another load.
+- A new member's first metadata establishes a baseline without invalidating the preview. Only subsequent metadata changes notify; initial reads have no additional version reconciliation.
+- Group changes set only `resourcesDirty` to `true`. Refresh clears it; another change during reading sets it again. No notification counter or separate read generation is added.
+- Closing the preview Tab releases the whole group. Disabling automatic refresh only stops automatic rereads; the group continues observing and recording stale state.
+
+This version covers direct stylesheet links and classic JS scripts already supported by the interactive HTML packer. It does not add CSS `@import`, ES module graph, or runtime network dependency discovery. A newly opened static safe preview reads no related files and observes only the root. Switching interactive HTML to static preview releases dependency watches and retains the root Resource.
+
+## Automatic-refresh controls
+
+Document Preview and Files each retain their existing reload button. The separate automatic-refresh icon control is temporarily hidden; its state, toggle logic, copy, and styles remain. New Tabs still default to `autoRefresh: true`. Hiding the control does not change its state or stop observation.
+
+| State or action | Behavior |
+|---|---|
+| Automatic refresh enabled | The control is highlighted, uses the existing pause icon, and offers “Disable auto refresh”; changes refresh the corresponding preview or directory node |
+| Disable | The control shows the play icon and offers “Enable auto refresh”; observation continues without automatically replacing displayed content |
+| Reenable | Refresh once immediately if changes occurred while paused, then resume automatic refresh |
+| Original reload button | Read again regardless of automatic-refresh state, without changing that state |
+| Expand a new directory while paused | The new directory still loads initially and subscribes; the switch controls only subsequent automatic rereads |
+
+Pause and play use existing icons; manual reload retains its circular arrow. No new icon system is added. The button exposes state through `aria-pressed`, with its name and tooltip in the existing locale dictionaries.
+
+## Complete Files-panel data flow
+
+```text
+Files.open
+ → FilesBody → Session.cwd + Tab
+ → filesFace.start → DirectoryNode.open
+ → changes(sessionId, path)
+ → Host.watch(depth: 0) → ready
+ → DirectoryNode → list(sessionId, path)
+ → createFilesStore.levels[path]
+ → Level / Entry
+
+Entry.expand
+ → filesFace.toggle
+ → DirectoryNode.expand → child.open
+ → child.watch → ready → child.list
+ → createFilesStore.expanded + levels[path]
+
+Process → create / remove / rename
+ → Host.change(directory)
+ → DirectoryNode.refresh → list(path)
+ → createFilesStore.levels[path]
+ → DirectoryNode.children ↔ list.entries
+ → DirectoryNode.collapse → child.close
+```
+
+File previews and the directory tree are independent consumers. A preview can refresh without an open Files panel; Files can update entries without any file preview. Neither a Resource for the whole tree nor a new directory Resource protocol is needed.
+
+The directory panel obtains invalidations and listings through injected Remote callbacks and writes displayed values through its own store. Components retain their existing read interfaces and do not directly hold Chokidar, Remote iterators, or low-level observation objects.
+
+## Directory-node tree and lifecycle
+
+The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class.
+
+| Node responsibility | Behavior |
+|---|---|
+| Directory identity | Hold this directory's path and Session/Tab ownership; filesystem roots and Windows drive roots are valid roots, and listings contain only direct entries |
+| Directory watch | One open node holds one target stream; notifications invalidate only this node's listing |
+| Directory read | Retain the active list generation and pending rereads through read completion; publish results through store actions |
+| Open children | Hold child `DirectoryNode` objects by direct-child path; unexpanded directories remain listing entries without active nodes |
+| Expand | A directory-row action creates or reuses its child, which subscribes and reads; restoration opens only directories still present in the listing |
+| Collapse | Remove and close the active child; recursively close its descendants and release its watch and requests |
+| Listing update | Retain open children still present; close deleted, renamed, or file-replaced branches without reopening every node |
+| Close tree | Closing the root recursively releases the active tree, without traversing another global watcher registry |
+
+`createFilesStore.expanded` retains user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. Expand and collapse actions update the store and pending restoration even when the parent node is not yet active; restored descendants follow the latest preferences instead of reopening a collapsed branch.
+
+```text
+workspace/ watch
+├── src/ open watch
+│ ├── components/ open watch
+│ └── internal/ closed -
+└── assets/ closed -
+
+collapse(src/):
+workspace/ watch
+├── src/ close(src/, components/)
+└── assets/ -
+```
+
+The parent's watch still detects deletion, renaming, or replacement of a collapsed child, but not changes deep inside it. Listing again on expansion discovers changes made while it was not watched.
+
+| User action or lifecycle event | File preview | Files panel |
+|---|---|---|
+| First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists |
+| Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences |
+| Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain |
+| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime |
+| Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers |
+| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state |
+| Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame |
+
+Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set.
+
+`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation.
+
+## User flows
+
+### Edit a previewed file
+
+1. The user opens Markdown, code, an image, PDF, HTML, or Office file from Files.
+2. The user saves it in an external editor or rewrites it through a shell command.
+3. Automatic refresh is enabled by default, so Document Preview displays new content. Disabling it retains current content until manual reload or reenabling.
+4. Renderer selection and wrapping preferences remain. Text scrolling uses existing restoration; shorter content limits the available position.
+5. Exact position restoration for PDF, HTML, and images is not an additional promise. Existing renderer behavior remains, without a cross-format location system for automatic refresh.
+
+### Browse changing directories
+
+1. The user opens Files; the root lists and begins observation.
+2. The user expands `src` and `src/components`; both start their own watchers.
+3. An external tool creates a file in `src/components`; only that level refreshes, without collapsing or reloading other directories.
+4. The user collapses `src`; its own watch and the `components` watch close, while the root remains watched.
+5. External changes continue while collapsed. Reopening `src` rereads it and progressively restores valid descendant expansion, rather than remaining on old cached entries.
+
+### Deletion, renaming, and failure
+
+- Deleting a file retains existing preview content with an unavailable notice; recreating its original path while its parent remains resumes reading. Renaming means disappearance at the old path and appearance at a new one, without rewriting the preview address from inode identity.
+- Deleting or renaming a directory refreshes its parent, removes the old row, and releases the old subtree. A new directory appears under its new name without transferring old-path expansion preferences.
+- Automatic and manual refresh invoke the same existing reload, retaining its clearing, loading, and error presentation. No old-byte or old-iframe retention branch is added. Directory refresh retains the existing listing cache to avoid rebuilding active subtrees.
+- When a backend cannot watch, the Host converts initialization failure into a `workspace-file/watch-unsupported` `RemoteError`. The Client ends observation while continuing ordinary reads and manual refresh, without polling. External SSH filesystem changes do not automatically update previews.
+
+## Readiness, races, and refresh coalescing
+
+Observation, reads, and presentation need a few explicit ordering rules, not disk transactions or an operation log.
+
+- Establish the watch before sending ready, then obtain initial stat/list. Do not discard changes arriving during a read; reread when necessary to converge on current state.
+- Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes.
+- Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs.
+- File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version.
+- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction.
+- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure.
+- Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate.
+- Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read.
+
+## Alternatives considered
+
+**Another Resource `onChange` and content reload.** Existing `open()`, `source()`, and `useResource` already express change, while the generic Resource does not own contents. Duplicate interfaces would make metadata, contents, and renderers each manage refresh state.
+
+**Recursive watching of the whole Workspace.** Opening one file or root directory should not watch every deep directory. Watching the current file and expanded directories ties resource use to what the user has actually opened.
+
+**Keep Session broadcasts and add path registration/unregistration.** The Host needs explicit targets, but not another path set maintained through additional commands. One target per stream directly reuses cancellation, reconnect, and ending semantics.
+
+**Push contents or complete listings.** This mixes observation with reads and duplicates pagination, byte limits, formats, and entry caps. Small notifications followed by existing `read()`/`list()` calls are more direct.
+
+**Apply directory-row additions and removals from Client events.** Filesystem events can coalesce or repeat, while listings own sorting, types, and entry limits. Relisting the affected level retains existing semantics without a directory-patch merge algorithm.
+
+**Call Chokidar directly in the Workspace API.** The API does not own the execution world; SSH paths may exist only remotely. The actual FS provider must implement watching.
+
+## Verification
+
+The owning FS, Workspace API, preview, and Files tests exercise watch lifetimes, metadata updates, and automatic refresh. The [Web document-preview test](../../../../apps/web/tests/document-preview.e2e.ts) exercises previews through the shipped Web composition. Session event logs, persistence formats, and model input are unchanged.
+
+| Scenario | Verified behavior |
+|---|---|
+| External in-place writes, atomic replacement, shell writes | Open previews refresh without depending on `fs/observed` |
+| Text, complete bytes, renderer-owned loading | All three modes refresh; old requests cannot overwrite new results, and Office failures allow a later file change to retry |
+| First member metadata | Establish a baseline without an extra reload or first-read version reconciliation |
+| Only CSS/JS changes, with unchanged HTML root | The group reloads the complete HTML |
+| HTML removes a dependency reference | Updating membership releases the unreferenced Resource |
+| Disable automatic refresh, modify, then reload or reenable | Content remains while paused and catches up on manual action or reenabling; the controls remain independent |
+| Root's direct entries are created, deleted, or renamed | The Files root level updates automatically, including when the workspace is a filesystem or Windows drive root |
+| An expanded child changes | Refresh only its level while preserving other levels and expansion preferences |
+| Unexpanded deep directories | No deep watcher or recursive traversal |
+| Collapse a parent | Release its watch and hidden descendants; an independently open file preview keeps watching |
+| Collapse or reexpand a deep descendant during ancestor restoration | Record the latest preference immediately; restoration opens only branches still expanded |
+| Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events |
+| Delete/recreate a file, or replace a child directory with a file | File watches recover at the same path while the parent remains; directory listings release invalid subtrees |
+| Rapid writes, duplicate notifications, changes during reads or their completion | Converge on current state with finite rereads, without endless refresh |
+| Close Tab, unload plugin, cancel initialization | Close all owned watches even when no further frame is requested; late notifications and reads no longer write state |
+| Unsupported providers or out-of-workspace directories | Report unavailable observation or access errors explicitly, without watching the wrong execution world or widening directory authority |
+
+Controlled streams and deferred reads verify readiness, cancellation, and expansion during restoration. Real local-watch tests use temporary directories, wait for readiness before acting, and finish on observable state rather than fixed sleeps. The Web preview test and owner-local UI tests cover user-visible behavior; executed command results belong in the PR.
+
+## Consequences
+
+- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred. Same-path file recreation while the parent remains stays supported.
+- OS file events are not durable messages. Disconnects and process restarts may lose notifications, so every stream generation obtains current state after readiness.
+- Open files and expanded directories increase watcher and stream counts. The first version controls this through on-demand lifetimes, without an advance cross-consumer deduplication service.
+- Automatic refresh may interrupt reading, especially HTML interactions and Office conversion. View preferences and coalescing remain, but arbitrary embedded-document runtime state is not preserved.
+- HTML CSS/JS members come from the existing parser. Deeper dependencies, image dependencies, and runtime loading are not additional parsing work in this change.
+- SSH OS watching needs its own remote implementation strategy. This interface declares unsupported operation honestly, without local watchers or implicit polling masquerading as remote support.
+
+## Relationship to existing Agent Notes
+
+Automatic refresh and OS watching retain the Resource model, file-read authorization, and renderer ownership. The following records retain independent rationale; this note owns target-scoped observation, automatic reload, and directory-watch lifetimes.
+
+- [Client Resource model](../../implemented/architecture/2026-09-05-client-resource-model.md): retain addresses, providers, subscriptions, and lifetimes; existing streams carry the new behavior.
+- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): read and listing responsibilities remain; target-scoped OS observation replaces the Session-scoped operation-only stream.
+- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): content-loading ownership and all three modes remain; automatic refresh uses their existing reload paths.
+- [Workspace file-read authority](../../implemented/architecture/2026-09-09-workspace-file-read-authority.md): retain distinct access scopes for file reading and directory browsing; target watches follow their respective rules.
+- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): per-Tab expansion, navigation, and scrolling remain; open nodes own automatic directory invalidation and watching.
diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md
new file mode 100644
index 0000000000..88fe6e7abb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md
@@ -0,0 +1,345 @@
+# Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新
+
+Status: implemented
+
+[English](2026-09-17-sidebar-file-and-directory-auto-refresh.md) | 中文
+
+## 问题
+
+Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。
+
+起始实现已有两条读取路径,但缺少完整的自动刷新链路。
+
+| 对象 | 起点行为 | 缺口 |
+|---|---|---|
+| 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch |
+| Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 |
+| Workspace 文件事件 | `WorkspaceFiles.changes()` 转发当前 Session 的 `fs/observed`,客户端按文件过滤 | Host 不知道具体关注哪些路径;外部编辑器和 Shell 修改不会产生这类事件 |
+| Files 面板 | `filesFace` 读取目录,`createFilesStore` 保存已加载层级及展开状态 | 新增、删除、重命名后需要手动刷新;展开过的目录再次打开可能直接展示旧缓存 |
+
+`Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。
+
+## 决策
+
+按需监听连接现有 FS、Workspace 文件流、Resource 和 Sidebar 组件。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。
+
+- Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。
+- Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。
+- 折叠目录时释放该目录及其不可见后代的监听;再次展开时重新订阅并读取,不能把折叠前缓存当作最新结果。
+- 监听只传元数据或失效通知;正文仍走现有文件读取接口,目录内容仍走 `list()`。
+- 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。
+- 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。
+
+变化通知触发现有 reload/list;刷新失败后不保留旧预览内容。下面说明实现对象的职责、数据流及验证。
+
+## 实现对象与职责
+
+### FS 与 Host
+
+| 类或类型 | 所在文件 | 职责 |
+|---|---|---|
+| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 |
+| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` |
+| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` |
+| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 |
+| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar |
+| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 |
+| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 |
+| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 |
+| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 仅传路径的请求沿用 `ready`/`change` 帧,不增加请求 kind 或独立目录帧格式 |
+
+### Client 与 Sidebar
+
+| 类、函数或类型 | 所在文件 | 职责 |
+|---|---|---|
+| `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 |
+| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` |
+| `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API |
+| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,独立自动刷新按钮暂时隐藏 |
+| `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` |
+| 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 |
+| `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 |
+| 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 |
+| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` |
+| 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 |
+| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController |
+| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 |
+| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 |
+
+## 监听接口与事件内容
+
+### 一条流对应一个明确目标
+
+每个请求指定一个目标路径;Host 通过 `stat` 确定当前实际类型,Client 不传文件/目录 kind。
+
+```text
+changes(sessionId, path, signal)
+changes(scope, path, signal)
+```
+
+Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。
+
+当前目标是目录时,Host 就检查 Workspace 包含关系;原本缺失或为普通文件的目标变成目录后也执行检查。Workspace 外的普通文件仍可通过所选 FS 读取与监听。
+
+| 通知 | 内容 | 消费方动作 |
+|---|---|---|
+| 就绪 | 当前一代目标监听已经建立 | 执行首次或重连后的 `stat()`/`list()`,接收读取期间排队的变化 |
+| 文件存在或更新 | 沿用路径和版本通知;Client 收到后重新 stat | 用完整结果更新 Resource 的路径、版本和大小;自动刷新开启时重新读取正文 |
+| 文件缺失 | 目标路径和缺失状态 | Resource 报告不可用,预览保留已加载内容并显示错误 |
+| 目录失效 | 目录目标流中的 `change` 帧 | 只重新调用这个目录的 `list()`,不按目录版本去重,也不刷新整棵树 |
+| 监听失败 | 初始化失败报告 `workspace-file/watch-unsupported`;运行期错误结束监听 | 客户端在监听接入处处理失败,普通读取与手动刷新仍可用;不伪装成文件删除 |
+
+不用把文件正文、完整目录列表或每个变化的补丁塞进通知。文件名由路径表达;文件大小来自当前 stat;修改时间若只为判断变化,继续由后端版本 token 表达,不要求 UI 解析 token 或另外引入时间戳比较。
+
+Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有小通知,再由 Client stat,避免为了减少一次请求扩大事件协议。目录变化不应只比较目录自身 mtime:直接子文件的大小或类型变化也可能影响 `list()` 返回值,应按相关子项事件使目录列表失效。
+
+### 系统监听与访问范围
+
+- `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。
+- 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。
+- 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。
+- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。本地提供方直接监听父目录,将条目和事件过滤到目标文件,等待父目录 watcher 就绪后才报告就绪,目标文件尚不存在时也适用。
+- 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。
+- 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。
+- 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。
+
+## 文件预览的完整数据流
+
+```text
+Files.openResource / file.link
+ → Sidebar.openTab
+ → TabDomain → Resource.pin
+ → ResourceRegistry → createFileResourceProvider.open
+ → changes(sessionId, path)
+ → WorkspaceFiles → FsTarget
+ → WorkspaceChangeFeed → FileSystem.watch
+ → LocalFileSystem → Chokidar
+ → ready → Client.stat → ResourceSnapshot
+
+Editor / Shell / file tool → disk
+ → Chokidar → LocalFileSystem
+ → WorkspaceChangeFeed → FileSystem.stat
+ → Remote.change
+ → Client.stat → ResourceSnapshot
+ → ResourceGroup → TextPreview
+ → reloadPages / reloadAll / prepareRenderer
+ → DocumentContent / renderer revision
+ → renderer
+```
+
+预览正文可以与元数据初始化并行读取。ResourceGroup 以各成员首次元数据为基线,只转发后续变化,不做首读版本对账。
+
+| 加载模式 | 已有刷新入口 | 刷新结果 |
+|---|---|---|
+| `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 |
+| `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 |
+| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求;Office 失败通过 `failed()` 结束加载,后续文件变化可重试 |
+
+普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。
+
+## HTML 的 ResourceGroup
+
+一个 HTML 预览的 group 包含 HTML 根文件,以及现有 HTML 打包器实际读取的 CSS/JS 文件。成员都是普通文件 Resource,不为依赖另建 watcher 或额外文件类型。group 是预览包内对象,复用 Resource 已有的订阅和持有计数。
+
+```text
+index.html Resource ─┐
+theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
+page.js Resource ───┘
+```
+
+- `addResource(address)` 在 `readRelated` 返回 Host 解析的 `absolutePath` 后将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。
+- `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。
+- CSS/JS 引用使用 HTML 所属 Session,由 Host 解析路径。读取失败时也保留 Host 提供的字符串 `error.details.path`;没有该路径时,Client 不自行猜测。
+- HTML 本身没有改变,但 CSS 或 JS 改变时,也重新加载整个 HTML,生成新的 iframe 内容。
+- 缺失依赖有 Host 报告的路径时仍保留为成员;解析失败时保留已经发现的依赖,后续成员变化可以再次触发加载。
+- 新成员的首次元数据仅建立基线,不使预览失效;只有后续元数据变化才通知,首次读取不另做版本对账。
+- group 变化只把 `resourcesDirty` 置为 `true`;开始刷新时清除,读取期间再次变化则重新置位。不累计次数,也不增加另一套读取代次。
+- 关闭预览 Tab 时释放整个 group。关闭自动刷新仅停止自动重读,group 继续观察并记录陈旧状态。
+
+此版覆盖交互式 HTML 打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。新打开的静态安全预览不读取关联文件,只观察根文件。从交互式 HTML 切换到静态预览时释放依赖监听,只保留根 Resource。
+
+## 自动刷新开关
+
+Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏不改变开关状态,也不停止观察。
+
+| 状态或操作 | 行为 |
+|---|---|
+| 自动刷新开启 | 开关高亮,使用已有暂停图标,提示“关闭自动刷新”;变化自动刷新对应预览或目录节点 |
+| 点击关闭 | 开关显示播放图标,提示“开启自动刷新”;继续观察变化,但不自动替换当前内容 |
+| 点击重新开启 | 若暂停期间已有变化,立即补一次刷新;之后继续自动刷新 |
+| 点击原 reload | 无论开关状态如何,都主动读取一次,不改变开关状态 |
+| 自动刷新关闭时展开新目录 | 新目录仍正常首次加载并订阅;开关只控制后续变化引起的自动重读 |
+
+暂停/播放使用现有图标,手动 reload 保留环形箭头,不新增图标系统。按钮通过 `aria-pressed` 表达状态,名称和提示走现有 locale 字典。
+
+## Files 面板的完整数据流
+
+```text
+Files.open
+ → FilesBody → Session.cwd + Tab
+ → filesFace.start → DirectoryNode.open
+ → changes(sessionId, path)
+ → Host.watch(depth: 0) → ready
+ → DirectoryNode → list(sessionId, path)
+ → createFilesStore.levels[path]
+ → Level / Entry
+
+Entry.expand
+ → filesFace.toggle
+ → DirectoryNode.expand → child.open
+ → child.watch → ready → child.list
+ → createFilesStore.expanded + levels[path]
+
+Process → create / remove / rename
+ → Host.change(directory)
+ → DirectoryNode.refresh → list(path)
+ → createFilesStore.levels[path]
+ → DirectoryNode.children ↔ list.entries
+ → DirectoryNode.collapse → child.close
+```
+
+文件预览和目录树是两条独立的消费路径。预览文件可以在 Files 面板没有打开时自动刷新;Files 也可以在没有任何文件预览时自动更新目录项。无需把整棵目录树包装为一个 Resource,也不为此新增目录 Resource 协议。
+
+目录面板直接通过注入的 Remote 业务回调取得失效通知和列表结果,显示值统一写回自己的存储;不绕过组件现有的读取方式,不让组件直接持有 Chokidar、Remote 迭代器或底层观察对象。
+
+## 目录节点树与生命周期
+
+Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。
+
+| 节点职责 | 具体行为 |
+|---|---|
+| 本目录身份 | 持有该目录路径和所属 Session/Tab,文件系统根与 Windows 盘根均可作为树根,列表只包含直接子项 |
+| 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 |
+| 本目录读取 | 持有有效 list 请求的代次,待补读状态持续到读取收尾后,结果通过存储 actions 发布 |
+| 已打开子目录 | 按直接子目录路径持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 |
+| 展开 | 目录行触发展开时创建或复用对应子节点,再由子节点建立自己的监听与读取;恢复展开偏好时只打开列表中仍存在的目录 |
+| 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 |
+| 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 |
+| 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 |
+
+`createFilesStore.expanded` 保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。即使父节点尚未创建,展开和折叠也会更新存储与待恢复状态;后代恢复时遵循最新偏好,不会重新打开已折叠的分支。
+
+```text
+workspace/ watch
+├── src/ open watch
+│ ├── components/ open watch
+│ └── internal/ closed -
+└── assets/ closed -
+
+collapse(src/):
+workspace/ watch
+├── src/ close(src/, components/)
+└── assets/ -
+```
+
+父目录的监听仍能发现折叠目录自身被删除、重命名或替换,但不负责发现其深层内容变化。再次展开时重新 list,补上未监听期间的变化。
+
+| 用户操作或生命周期事件 | 文件预览 | Files 面板 |
+|---|---|---|
+| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 |
+| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 |
+| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 |
+| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 |
+| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher |
+| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 |
+| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 |
+
+Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。
+
+同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。
+
+## 用户可见动线
+
+### 编辑已预览的文件
+
+1. 用户从 Files 打开 Markdown、代码、图片、PDF、HTML 或 Office 文件。
+2. 用户在外部编辑器保存,或 Shell 命令改写该文件。
+3. 自动刷新默认开启,Document Preview 自动显示新内容;用户关闭开关后可保持当前内容,手动 reload 或重新开启时再读取。
+4. 当前渲染器选择和换行偏好保留;文本滚动位置按现有机制恢复,内容缩短时只能落在新内容允许的位置。
+5. PDF、HTML 和图片的精确阅读位置恢复不是本方案额外承诺;沿用各渲染器现有能力,避免为自动刷新加入一套跨格式定位系统。
+
+### 浏览正在变化的目录
+
+1. 用户打开 Files,根目录列表加载并开始监听。
+2. 用户展开 `src` 和 `src/components`,两个子目录分别开始监听。
+3. 外部工具在 `src/components` 创建文件,只有这一层列表刷新,其他目录不折叠、不重新加载。
+4. 用户折叠 `src`,其自身和 `components` 的监听关闭,根目录继续监听。
+5. 折叠期间外部工具继续修改内容;用户再次展开 `src` 时重新读取,依次恢复仍有效的子目录展开状态,不停留在旧缓存。
+
+### 删除、重命名和失败
+
+- 文件删除后,已有预览内容保留并显示不可用提示;父目录保持存在时,原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。
+- 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。
+- 自动刷新和手动刷新调用同一套已有 reload,沿用清空旧结果、加载及错误展示行为;不增加保留旧字节或旧 iframe 的分支。目录刷新保留现有列表缓存,避免重建活动子树。
+- 后端不支持 watch 时,Host 把初始化异常转成 `workspace-file/watch-unsupported` 的 `RemoteError`;客户端结束监听,继续普通读取与手动刷新,不增加轮询。SSH 的外部磁盘变化不会自动更新预览。
+
+## 就绪、竞态与刷新合并
+
+监听、读取与展示只需要处理少数明确的顺序问题,不扩展成磁盘事务或操作日志。
+
+- 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。
+- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。
+- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。
+- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。
+- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。
+- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。
+- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。
+- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。
+
+## 不采用的替代方案
+
+**在 Resource 上再加一套 `onChange` 和正文 reload。** 现有 `open()`、`source()`、`useResource` 已经表达变化,正文又不归通用 Resource 所有。重复接口会使元数据、正文和渲染器分别维护刷新状态。
+
+**整个 Workspace 递归 watch。** 打开一个文件或一个根目录不应导致监听所有深层目录。按当前文件和展开目录取得监听,资源使用量与用户实际打开的内容相关。
+
+**保留 Session 广播流,再增加路径注册/退订协议。** Host 需要知道具体目标,但不必再维护一个由额外命令修改的路径集合。一目标一流可以直接复用现有取消、重连和结束语义。
+
+**直接下发正文或完整目录列表。** 这会混合监听与内容读取,还要重复分页、字节限制、文件格式和目录条目限制。小通知配合现有 `read()`/`list()` 更直接。
+
+**根据变化事件在客户端增删目录行。** 文件系统通知可能合并或重复,目录列表还有排序、类型和条目上限。重新 list 受影响的一层即可保持现有语义,不需要维护目录增量合并算法。
+
+**在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。
+
+## 验证
+
+FS、Workspace API、预览和 Files 各自的测试覆盖监听生命周期、元数据更新和自动刷新。[Web 文档预览测试](../../../../apps/web/tests/document-preview.e2e.ts) 从正式 Web 组合验证文件预览。会话事件日志、持久化格式和模型输入均不变。
+
+| 场景 | 已验证行为 |
+|---|---|
+| 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` |
+| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 |
+| 成员首次元数据 | 仅建立基线,不额外 reload,也不做首读版本对账 |
+| HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 |
+| HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 |
+| 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 |
+| 根目录直接子项新增、删除、重命名 | Files 根层自动更新,工作区为文件系统根或 Windows 盘根时也适用 |
+| 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 |
+| 未展开的深层目录 | 不创建深层 watcher,不递归遍历 |
+| 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 |
+| 祖先恢复期间折叠或重新展开深层后代 | 立即记录最新偏好,恢复时只打开仍需展开的分支 |
+| 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 |
+| 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 |
+| 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 |
+| 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 |
+| 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 |
+
+可控事件源和延迟读取验证就绪、取消及恢复期间的展开操作。真实本地 watch 测试使用临时目录,等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。Web 预览测试和包内 UI 测试覆盖用户可见行为;命令执行结果记录在 PR 中。
+
+## 影响
+
+- Linux 上父目录删除并重建后的自动监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。
+- OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。
+- 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。
+- 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。
+- HTML 的 CSS/JS 通过现有解析器进入 group;更深依赖、图片依赖和运行时动态加载不作为这次修改的额外解析工程。
+- SSH 远端系统监听需要独立的远端实现策略;这里要求接口如实表达不支持,不使用本机 watcher 或隐式轮询冒充远端能力。
+
+## 与已有 Agent Note 的关系
+
+自动刷新和系统监听保留已有 Resource 模型、文件读取授权和渲染器职责。以下记录仍有独立的设计理由;本文负责目标级监听、自动 reload 和目录监听生命周期。
+
+- [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。
+- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):读取与列表接口职责不变;目标级系统监听替代仅转发操作观察的 Session 流。
+- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):正文加载所有权和三种加载方式不变;自动刷新使用其现有 reload 路径。
+- [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。
+- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):按 Tab 的展开、导航和滚动状态不变;已打开节点负责目录自动失效与监听。
diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml
new file mode 100644
index 0000000000..d86602a99e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.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-17-state-dot-visual-language.md
+2026-09-17-state-dot-visual-language.md: 751abbaa33919721c7e71738ad9b6bed744fcad7
+2026-09-17-state-dot-visual-language.zh.md: 9746ec9af79e15fc41ea8e6601cc52c1cfafba02
diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md
new file mode 100644
index 0000000000..751abbaa33
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md
@@ -0,0 +1,31 @@
+# Agent Note: StateDot solid marks and loading spinner
+
+Status: implemented
+
+English | [中文](2026-09-17-state-dot-visual-language.zh.md)
+
+## Problem
+
+`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase.
+
+## Decision
+
+`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the neutral `--dsw-alias-state-idle-primary` token, `done` remains success green, `warning` remains amber, and `error` remains red.
+
+`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change.
+
+Converted compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state; their collapsed summary turns red for failure and amber for `stopped`, preserving any tool-specific interruption text. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available.
+
+## Alternatives considered
+
+**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state.
+
+**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo.
+
+## Testing
+
+The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Tool, Bash, and Skill row specs pin the retained business glyph plus visible red failure and amber interruption summaries. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels.
+
+## Consequences
+
+Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state.
diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md
new file mode 100644
index 0000000000..9746ec9af7
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: StateDot 纯色标记与旋转 loading
+
+Status: implemented
+
+[English](2026-09-17-state-dot-visual-language.md) | 中文
+
+## 问题
+
+`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。
+
+## 决策
+
+`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用中性的 `--dsw-alias-state-idle-primary` token,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。
+
+`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。
+
+已转换的紧凑纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标;收起摘要在失败时变红,在 `stopped` 时变为琥珀色并保留工具自有的中断文本。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。
+
+## 考虑过的替代方案
+
+**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。
+
+**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。
+
+## 测试
+
+组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Tool、Bash 与 Skill 行测试固定保留的业务图形,以及可见的红色失败摘要与琥珀色中断摘要。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。
+
+## 后果
+
+消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml
new file mode 100644
index 0000000000..b793133d0f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.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-18-session-pin-and-sidebar-archive.md
+2026-09-18-session-pin-and-sidebar-archive.md: 262f7b76d6ac7eee21a646eedc93b7d936c86675
+2026-09-18-session-pin-and-sidebar-archive.zh.md: 86ddf4b58781c5974b93562396c3205cfdd0f9dc
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
new file mode 100644
index 0000000000..262f7b76d6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
@@ -0,0 +1,79 @@
+# Agent Note: Session pinning and the sidebar archived filter
+
+Status: implemented
+
+English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md)
+
+## Problem
+
+The sidebar session list had no way to keep important sessions at the top, and archived sessions could only be reached through a settings page (`ui-settings-unarchive-sessions`) far from the list they came from. Users archived a session and then lost sight of it.
+
+A visibility filter can hide a Session without removing it from its Workspace. Saving filtered rows as the account's complete order loses hidden positions. Independent pin ordering also makes dragging depend on a second sequence instead of the Session order the user edits.
+
+## Decision
+
+The `dsh-workspace` registry stores registry-global pin and archive sets as Session id arrays (`pinnedSessionIds` and `archivedSessionIds`), with durable `pinSession`/`unpinSession` operations. The pin array keeps the most recently pinned id first. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`.
+
+### Complete Session order
+
+Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules.
+
+- Existing members retain their positions. Missing pinned members prepend in the pin array's order. New ordinary forks enter immediately before their source, including nested forks, without inheriting its pin. Saved members keep their relative order, and visible pinned rows still lead ordinary forks. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never duplicates an id.
+- Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions.
+- A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members.
+- The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps.
+- Archive takes precedence if both sets contain a Session. Archiving clears the Host pin but retains the Session's account position. The archive filter changes visibility and disabled-row presentation, not membership or ordering.
+
+The existing [mode-switch and provisional-New-Session rules](../simplification/2026-09-10-derived-workspace-recency.md) remain: a drag selects Manual, Last updated discards the prior manual layout, entering Manual captures the current chronological order, and the selected blank Session retains its provisional first slot. An explicit Pin updates saved positions without changing the selected mode; chronological display still ignores those positions.
+
+### Archive restoration
+
+`WorkspaceRegistry.unarchiveSession` removes one id from the global archive set on the same serialized operation chain as archive and other registry writes. An absent id is an idempotent no-op with no durable write or notification. Restoration preserves Workspace membership and Session logs, and it neither restores a pin nor changes saved ordering.
+
+Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row.
+
+### Archived visibility and row motion
+
+The sidebar derives its visible rows in `ui-workspace`:
+
+- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package.
+- Archiving raises a toast with undo and filter-archived actions. Parent rerenders do not extend its lifetime; the fully faded banner cannot retain clickable actions.
+- Row motion uses a private React component with native position and opacity animations. It captures row positions around structural commits without observers or polling; removed rows leave inert fading copies outside the scrolling list. Initial loading, drag commits, overflow expansion/collapse, and grouping/ordering/filter switches settle immediately.
+
+### Persistence
+
+Workspace KV, Remote values, and Client snapshots store archive and pin membership as Session id arrays, without object wrappers or pin timestamps. Missing collections default to empty; object-form pin entries are not accepted. Pinning and restoration do not change the Workspace domain version or Session-log format, and neither operation edits Session logs.
+
+## Alternatives considered
+
+**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright.
+
+**A restore action without an archived-row filter.** Hidden rows cannot expose actions. The filter makes those rows reachable, so restoration can live with the Session list instead of a separate settings page.
+
+**A separate restore delta or an existence check on unarchive.** A second frame type duplicates the complete-set response and requires another merge path. An existence probe would turn removal of an orphan archive id into an error even though removal cannot introduce an unresolved id. Restoration therefore shares the archive-set protocol and omits the probe.
+
+**Render archive ids whose Session summaries are missing.** Such entries could explain orphan state, but they cannot provide the normal conversation row. The sidebar displays loaded summaries; direct restoration remains available through the API, and orphan management remains outside this browser.
+
+**A separately editable pin sequence.** This duplicates ordering authority and requires another drag write path. The global pin array only seeds missing pinned members; established positions belong to the complete Session sequence.
+
+**Restore pre-pin or pre-archive positions.** Keeping Pin separate from manual positions would restore pre-pin slots, but Pin is an intentional reorder. A separate restoration record adds a product guarantee that this interaction does not require; hiding an archived row already preserves its existing position.
+
+**Rank pins by their pin timestamp.** This makes Last updated mean something other than Session activity. A newly pinned old Session need not lead the pinned partition in that mode; Manual shows the Pin action's first position.
+
+**Persist every derived or filtered list.** Rendering and visibility changes are not ordering commands. An incomplete or filtered projection cannot replace the complete saved sequence.
+
+**Animate every list pass.** Revealing hidden rows, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly while pin jumps, archive fades, and neighboring-row movement retain their animations.
+
+## Consequences
+
+The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. Row animation owns no persistent state or dependency.
+
+Filtering and reordering share one complete membership sequence, so hidden archives survive drags and filter switches. Older saved orders recover omitted members in memory and adopt the supplemented sequence on their next Session-order write. Host pin/archive membership semantics remain unchanged. The browser gives up pin-time promotion in Last updated and automatic restoration of pre-pin positions.
+
+An archived id without a loaded Session summary has no visible row or sidebar restore action, even though the API can remove it. This UI limit does not change the registry's idempotent restoration behavior.
+
+Unit suites cover supplementation, complete-sequence dragging, Pin completion after intervening changes, New Session priority, archived navigation guards, filters, toast lifetime, and animation lifecycle. Workspace and Controller tests cover pin/archive exclusivity, identity-only persistence and snapshots, idempotent no-op restoration, missing-Session restoration without persistence probes, restart recovery, and stale unary replies superseded by newer requests or pushed state. The keyless sidebar restore scenario verifies the row action and reload durability.
+
+## Related
+
+- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set and its follow increment.
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md
new file mode 100644
index 0000000000..86ddf4b587
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md
@@ -0,0 +1,79 @@
+# Agent Note: Session pinning and the sidebar archived filter
+
+Status: implemented
+
+[English](2026-09-18-session-pin-and-sidebar-archive.md) | 中文
+
+## Problem
+
+侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。
+
+显隐筛选可以隐藏会话,但不改变它的 Workspace 归属。把筛选后的行保存成所属列表的完整顺序,会丢失隐藏项的位置。单独维护可编辑的置顶顺序,也会让拖拽依赖另一份序列,而不是用户正在编辑的会话顺序。
+
+## Decision
+
+`dsh-workspace` registry 将全局置顶与归档集合存为会话 id 数组(`pinnedSessionIds` 和 `archivedSessionIds`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶数组把最近置顶的 id 放在前面。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
+
+### 完整会话顺序
+
+每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。
+
+- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。新增的普通 fork 紧邻来源之前插入,包括连续 fork,不继承来源的置顶。已保存成员保持相对顺序,可见置顶行仍排在普通 fork 之前。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不重复加入同一个 id。
+- 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。
+- 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。
+- 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。
+- 会话同时命中两个集合时,归档优先。归档会清除 Host 的置顶标记,但保留会话在所属序列中的位置。归档筛选只改变可见性和禁用行的展示,不改变成员关系或排序。
+
+既有的[模式切换与临时新会话规则](../simplification/2026-09-10-derived-workspace-recency.zh.md)保持不变:拖拽选择手动模式,最近更新丢弃先前的手动布局,进入手动模式记录当前时间顺序,当前选中的空白会话保留临时首位。显式置顶更新保存的位置,但不切换所选模式;时间视图仍忽略这些位置。
+
+### 取消归档
+
+`WorkspaceRegistry.unarchiveSession` 与归档及其他注册表写入共用串行操作链,从全局归档集合移除一个 id。id 不在集合中时是幂等空操作,不产生持久写入或通知。恢复保留 Workspace 成员关系与 Session 日志,不恢复置顶,也不改变保存的顺序。
+
+取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行。
+
+### 归档显隐与行动画
+
+侧边栏在 `ui-workspace` 中派生可见行:
+
+- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。
+- 归档动作弹出携带撤销与筛选已归档操作的 toast。父组件重渲染不会延长其生命周期,完全淡出后不会保留可点击操作。
+- 行动画由私有 React 组件使用浏览器原生位移与透明度动画实现。组件在结构性提交前后记录行位置,不使用观察器或轮询;被移除的行在滚动列表外留下不可交互的淡出副本。初始加载、拖拽提交、展开/收起其余会话,以及分组/排序/筛选切换直接完成。
+
+### 持久化
+
+Workspace KV、Remote 值与 Client 快照都用会话 id 数组存储归档和置顶成员,不包含对象包装或置顶时间戳。缺失集合默认为空,不接受对象形式的置顶条目。置顶与恢复不改变 Workspace 领域版本或 Session 日志格式,也不编辑 Session 日志。
+
+## Alternatives considered
+
+**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。
+
+**只提供恢复操作,不提供已归档筛选。** 隐藏行无法提供操作。筛选让这些行可以被访问,因此恢复可以与 Session 列表放在一起,而不需要独立设置页。
+
+**独立恢复增量,或在取消归档时检查存在性。** 新增帧类型会重复完整集合响应,并增加合并路径。存在性探测会把移除孤立归档 id 变成错误,尽管移除不可能引入未解析 id。因此恢复复用归档集合协议,不执行该探测。
+
+**显示缺少 Session 摘要的归档 id。** 这些条目可以解释孤立状态,但不能提供普通对话行。侧边栏显示已加载摘要,API 仍支持直接恢复;孤立项管理不属于当前浏览器。
+
+**单独维护可编辑的置顶序列。** 这会产生两个排序来源,也需要另一条拖拽写入路径。全局置顶数组只为缺失置顶成员提供初始位置,已有位置由完整会话序列管理。
+
+**恢复置顶或归档之前的位置。** 让置顶独立于手动位置可以恢复置顶前的位置,但置顶本身就是一次有意的重排序。另存恢复记录增加了这套交互不需要的产品保证;隐藏归档行本身已经保留其已有位置。
+
+**用置顶时间给置顶行排名。** 这会让最近更新不再严格表达会话活动。在该模式下,新置顶的旧会话不必排在置顶区首位;手动模式才显示置顶操作调整后的首位。
+
+**保存每次派生或筛选后的列表。** 渲染和显隐变化不是排序命令。不完整或经过筛选的投影不能覆盖完整的已保存序列。
+
+**为每次列表变化播放动画。** 显示隐藏行、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,而置顶跳动、归档淡出和相邻行补位仍保留动画。
+
+## Consequences
+
+侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。
+
+筛选与重排序共用一条完整成员序列,隐藏的归档项不会因拖拽或切换筛选丢失。旧排序遗漏的成员在内存中补齐,并在下一次会话顺序写入时随完整序列保存。Host 的置顶与归档成员语义保持不变。浏览器不再用置顶时间提升最近更新模式下的位置,也不自动恢复置顶前的位置。
+
+归档 id 没有已加载 Session 摘要时,不会产生可见行或侧边栏恢复操作,尽管 API 仍能移除它。这个 UI 限制不改变注册表的幂等恢复行为。
+
+单元测试覆盖补齐、完整序列拖拽、期间发生其他变更后的 Pin 完成、新会话优先级、归档导航保护、筛选、toast 生命周期与行动画生命周期。Workspace 和 Controller 测试覆盖置顶与归档互斥、只含身份的持久化及快照、幂等空操作恢复、缺失 Session 时不探测持久化的恢复、重启恢复,以及被新请求或推送状态取代的旧一元响应。无密钥的侧边栏恢复场景验证行操作与重载后的持久性。
+
+## 相关
+
+- [Session 归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md) — 归档集合及其 follow 增量的冻结记录。
diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
index b6d13dd1aa..23d245c8c7 100644
--- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b
-2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4
+2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d
+2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee
diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
index 10123fe199..c2fd2fa6cf 100644
--- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
+++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
@@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded.
1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
-3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
+3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Self-hosted coverage uses four single-worker instrumented partitions and two exempt workers; isolated hosted runners derive worker defaults from available CPUs. The Windows switch selects the native Windows pool and retains explicit worker limits on shared self-hosted runners.
**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
index cff99e6f64..7fa5887bb5 100644
--- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
@@ -32,7 +32,7 @@ Status: implemented
1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。
2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
-3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
+3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。自托管覆盖率使用 4 个单 worker 插桩分区与 2 个豁免 worker;独占的托管运行器根据可用 CPU 计算默认 worker 数。Windows 开关选择原生 Windows 运行器池,并在共享的自托管运行器上保留显式 worker 限制。
**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
index 5b23d66658..c8bfb9d43c 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
-2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1
-2026-07-26-frozen-agent-note-archive.zh.md: 2c2751c56a9b20423b00eac7a90c02286ce3e8b0
+2026-07-26-frozen-agent-note-archive.md: e9ecfa78f53a5912608201654e2c56e399defccd
+2026-07-26-frozen-agent-note-archive.zh.md: 9a3ce2e6e21864f2754f627be6f7576bfc1f9076
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
index 0c139c4a5d..e9ecfa78f5 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
@@ -10,9 +10,9 @@ Implemented Agent Notes are maintained as current decision records, so every pat
## Decision
-Only implemented Agent Notes can be archived. An implemented note moves when its shipped decision is complete and its rationale, alternatives, consequences, negative guarantees, and reintroduction conditions are unlikely to guide future work. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet.
+Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted as complete triplets, with inbound links repaired or removed. A small implementation can still carry lasting naming, compatibility, ownership, or behavior decisions; those records remain eligible for retention. Only other implemented Agent Notes can be archived: the shipped decision is complete, its rationale is unlikely to guide future work, and it still has historical decision value. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet.
-The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits.
+The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. Archival admission checks lifecycle metadata and completeness, not title punctuation, blank-line layout, or language-switcher wording; requiring those repairs would rewrite the historical record.
The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery.
@@ -42,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to
## Consequences
-The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history.
+The active corpus becomes a set of decisions expected to influence future work, while substantive historical decisions with little future guidance remain explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history.
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
index 2c2751c56a..9a3ce2e6e2 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
@@ -10,9 +10,9 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
## 决策
-只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。
+仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 会作为完整的三文件配对删除,并修复或移除入站链接。小规模实现仍可能承载长期有效的命名、兼容性、归属或行为决策;此类记录仍可保留。只有其他 implemented Agent Note 可以归档:交付决策已经完整落地,其依据不太可能再指导未来工作,但仍具有历史决策价值。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。
-归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。
+归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 归档准入检查生命周期元数据与完整性,不检查标题标点、空行布局或语言切换栏措辞;要求修复这些格式会改写历史记录。
根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。
@@ -42,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
## 后果
-活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。
+活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低但具有实质内容的历史决策仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
index a542599011..7ad3923251 100644
--- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
-2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77
+2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe
+2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
index e4fc7cab8c..64c4366ee8 100644
--- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
@@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions
The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
-Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
+Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes.
-`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
+`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict.
-`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
+`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
@@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
-**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
+**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
@@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
## Consequences
-Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
+Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure.
-Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
+Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
index 27ad602c37..48725cdc58 100644
--- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
@@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
[ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
-每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
+每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。
-`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
+`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。
-`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
+`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
@@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
-**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
+**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。
@@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
## 后果
-Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
+Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。
-尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
+每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。
diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml
deleted file mode 100644
index 37bf7d8e0c..0000000000
--- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md
-2026-09-07-issue-policy-module-ownership.md: 696343ddffa3f6a6e411330d038bb4a470ce78d8
-2026-09-07-issue-policy-module-ownership.zh.md: abe539a5baed35cf2f356194eab8e53d61b67314
diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md
deleted file mode 100644
index 696343ddff..0000000000
--- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: Issue policy module ownership
-
-Status: implemented
-
-English | [中文](2026-09-07-issue-policy-module-ownership.zh.md)
-
-## Problem
-
-Issue validation and Project lifecycle processing use the same reference parser, metadata rules, and GitHub reads, but they make different decisions and perform different writes. Keeping those responsibilities in the command entry makes it harder to reuse policy rules without also depending on event dispatch and output handling.
-
-## Decision
-
-Issue management separates pure decisions, GitHub access, pull-request evaluation, lifecycle mutations, and command dispatch into owner modules. [The owner reference](../../../../.github/issue-management/README.md#module-ownership) maps those responsibilities to source files. Callers and tests import the module that owns the operation rather than using the command entry as an export collection.
-
-The separation preserves policy results, diagnostics, credentials, API requests, lifecycle mutations, and workflow entry commands. [Selective evaluation](2026-09-07-selective-issue-policy-evaluation.md) remains the independent owner of eligibility, Project-read selection, and event scheduling; this decision does not redefine those behaviors.
-
-## Alternatives considered
-
-**Keep one command module.** A single file avoids imports between local owners, but ties reusable decisions and GitHub access to command handling. Separate owners let PR validation and lifecycle processing share their existing rules and reads without sharing command dispatch.
-
-## Consequences
-
-Maintainers can locate a rule, network operation, or event handler by responsibility. Reuse occurs through ordinary local ESM imports, not a new package or plugin API. The added modules introduce imports that must stay coordinated when a shared function changes.
-
-Module separation does not grant stronger credentials, change check authority, serialize Project mutations, or prevent a future behavior regression. Existing lifecycle races and field-management limitations remain documented by their behavior owners.
-
-## Verification
-
-[Policy tests](../../../../.github/issue-management/policy.test.mjs) exercise the owner modules and check observable policy and lifecycle behavior. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) cover the command wiring and workflow declarations. Their evidence concerns the checked implementation, not a guarantee that later edits preserve behavior.
diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md
deleted file mode 100644
index abe539a5ba..0000000000
--- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Agent Note: Issue 策略模块归属
-
-Status: implemented
-
-[English](2026-09-07-issue-policy-module-ownership.md) | 中文
-
-## 问题
-
-Issue 校验与 Project 生命周期处理使用相同的引用解析器、元数据规则和 GitHub 读取,但各自作出不同决策、执行不同写入。将这些职责放在命令入口中,会使复用策略规则时更难摆脱对事件分派与输出处理的依赖。
-
-## 决策
-
-Issue 管理将纯决策、GitHub 访问、PR(Pull Request)求值、生命周期 mutation 和命令分派分配给各自的所属模块。[所属参考文档](../../../../.github/issue-management/README.zh.md#module-ownership)将这些职责对应到源文件。调用方和测试直接导入拥有该操作的模块,不把命令入口用作导出集合。
-
-此职责分离保留策略结果、诊断、凭据、API 请求、生命周期 mutation 和工作流入口命令。[选择性求值](2026-09-07-selective-issue-policy-evaluation.zh.md)仍独立拥有强制范围、Project 读取选择和事件调度规则;本决策不重新定义这些行为。
-
-## 考虑过的替代方案
-
-**保留单一命令模块。** 单文件可以避免本地所属模块之间的导入,但会把可复用的决策与 GitHub 访问绑定到命令处理。独立的所属模块让 PR 校验和生命周期处理可以共享既有规则与读取,而无需共享命令分派。
-
-## 影响
-
-维护者可以按职责定位规则、网络操作或事件处理器。复用通过普通的本地 ESM 导入完成,而不是新增包或插件 API。新增模块引入的导入关系需要在共享函数变更时同步维护。
-
-模块分离不会授予更强的凭据、更改检查权威来源、串行化 Project mutation,也不能防止未来的行为回归。既有生命周期竞态与字段管理限制仍由各自的行为文档记录。
-
-## 验证
-
-[策略测试](../../../../.github/issue-management/policy.test.mjs)运行所属模块,并检查可观察的策略与生命周期行为。[工作流测试](../../../../scripts/ci-workflow.spec.ts)覆盖命令接线与工作流声明。这些证据针对被检查的实现,不保证后续编辑仍保留行为。
diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
index 384d675f4c..bfd50afb64 100644
--- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
-2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137
-2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d
+2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12
+2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2
diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
index d4be1ad25c..4fb4ed4ccb 100644
--- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
+++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
@@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp
## Decision
-`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
+`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
-Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
+Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch.
diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
index cd6e74aefb..c2c4ee76a3 100644
--- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
+++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
@@ -10,9 +10,9 @@ Status: implemented
## 决策
-`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
+`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
-`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
+`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。
diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
index bd4f67275d..43d5e94cad 100644
--- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
-2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70
-2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75
+2026-09-09-parallel-macos-notarization.md: 18358444426575e27a21f0f56541e22e37fcdf2c
+2026-09-09-parallel-macos-notarization.zh.md: a69d586eff1413debe096656a7572a53f0938e98
diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
index 152da46612..1835844442 100644
--- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
+++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
@@ -10,11 +10,11 @@ The Desktop release distributes a DMG for installation and a ZIP for updates. Wa
## Decision
-The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
+The fixed-target installer command assembles one App, writes and verifies its `app-update.yml`, then signs it and creates two independent copies with `ditto`. Writing the configuration explicitly is required because electron-builder skips its macOS update-config hook when the initial build has only the directory target, while the later `--prepackaged` invocations do not reassemble the App. The App lane notarizes and staples its copy, verifies its signature, ticket, Gatekeeper acceptance, and update configuration, and asks electron-builder to create the ZIP and updater metadata. The DMG lane verifies its copy's update configuration, immediately packages it, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App.
-Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
+Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes with the configured update feed allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. Missing or mismatched App update configuration fails before promotion; any error leaves the record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements.
@@ -34,4 +34,4 @@ On 2026-09-09, full arm64 packaging on the same Mac through the same system prox
Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process.
-The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.
+The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, update-configuration enforcement, and refusal to promote incomplete artifacts. Focused configuration tests cover writing the fixed feed and rejecting missing, mismatched, or incomplete fields. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.
diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
index d5375e39a6..a69d586eff 100644
--- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
+++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md
@@ -10,11 +10,11 @@ Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 Ap
## 决策
-固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
+固定目标安装包命令先组装一个 App,写入并验证其中的 `app-update.yml`,随后签名,再通过 `ditto` 创建两个独立副本。必须显式写入该配置,因为初始构建只有目录目标时,electron-builder 会跳过 macOS 更新配置钩子,而后续 `--prepackaged` 调用不会重新组装 App。App 路线公证其副本并钉票,验证签名、票据、Gatekeeper 接受状态与更新配置,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线验证其副本的更新配置,随后立即封装副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。
-错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
+错误传播和临时目录清理前必须等待两路均结束。只有两路均成功且使用已配置更新源时,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。App 更新配置缺失或不匹配会在移入前失败;任何错误都会使该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。
@@ -34,4 +34,4 @@ ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据
两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。
-[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。
+[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误、强制检查更新配置,以及拒绝移入不完整产物。聚焦配置测试覆盖固定更新源的写入,并拒绝缺失、不匹配或字段不完整的配置。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。
diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml
index 238d996567..94e92c9bac 100644
--- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md
-2026-09-11-persistence-type-history.md: e51e9d565718a752ad4209010665f244725db102
-2026-09-11-persistence-type-history.zh.md: 10dfd0565c710c2714c8f87dd290eb351c572a90
+2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e
+2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a
diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md
index e51e9d5657..c7cddea5ec 100644
--- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md
+++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md
@@ -10,21 +10,25 @@ A persisted event can retain the same payload type expression while a referenced
## Decision
-One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, comments, and property order are presentation details outside the digest. Historical records retain source file paths without line numbers because their selected source trees differ from the current checkout. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
+One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, ordinary comments, and property order are presentation details outside the digest. Explicit compatibility annotations carry fingerprinted policy data under the [source-attribution policy](2026-09-17-persistence-schema-review.md). Historical records retain source file paths without line numbers because their selected source trees differ from the current checkout. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services.
-Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
+Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once.
Retrospective [release comparisons](../../../../docs/persistence-changes/releases/README.md) use a separate `persistence-release` kind and pinned tag manifest. They retain changed after schemas and reconstruct adjacent releases, including unchanged tags. Earlier releases predate these acknowledgement rules, so their classifications are informational and their observed versions are not rewritten to satisfy a modern version floor. Historical parsing preserves optional surface operations; the current parser and acknowledgement chain remain strict. This separation permits approximate backfills without turning them into authorization for current changes.
-The [format references](../../../../docs/persistence-changes/historical-formats/README.md) preserve complete inventories for every historical integer below the writer constant. The current writer retains the existing generated catalog. Coverage is derived from the constant rather than a hand-maintained version manifest, so advancing the writer requires archiving its predecessor. The archive command copies the validated current inventory, retains its root-reachable definitions, removes source line numbers, and refuses to overwrite an existing snapshot. Source checkpoints remain explicit because event additions and optional payload changes can occur within one format; these snapshots do not authorize current changes or claim migration safety. Historical identifiers are retained in machine schemas and exactly generated schema regions, while current terminology checks still cover the surrounding authored prose.
+The [format references](../../../../docs/persistence-changes/historical-formats/README.md) preserve complete inventories for every historical integer below the writer constant. The current writer retains the existing generated catalog. Coverage is derived from the constant rather than a hand-maintained version manifest, so advancing the writer requires archiving its predecessor. The archive command copies the validated current inventory, retains its root-reachable definitions, removes source line numbers, and refuses to overwrite an existing snapshot. Source checkpoints remain explicit because event additions and optional payload changes can occur within one format; these snapshots do not authorize current changes or claim migration safety. Historical identifiers and source package paths are retained in machine schemas and exactly generated schema regions, while current terminology and package-path checks still cover the surrounding authored prose and current catalogs.
Record commands generate the bilingual catalog and consistency records from repository-owned templates. Authors can supply the two languages' summary, compatibility reasoning, and actual verification evidence as structured input. Generation supplies identifiers, digests, and snapshots; it never invents a compatibility explanation or test result. Structured check output retains a nonzero failure exit status and reports stable change kinds independently of human-readable descriptions.
-Explicit update refreshes an unaccepted terminal record without deleting its authored prose. It recomputes the transition against the remaining history and rejects the baseline or any record with dependants. Review acceptance is not a fact available from the tree, so authors preserve accepted records and add successors.
+Explicit update refreshes an unaccepted terminal record without deleting its authored prose. It recomputes the transition against the remaining history and rejects the baseline, any record with dependants, or a finalized record. Review acceptance outside finalized checkpoints is not inferred from the tree, so authors preserve accepted records and add successors.
+
+### Finalized checkpoints
+
+Finalization records acceptance independently of publication. A terminal record must not reuse its original header increase to authorize another breaking change to already-written data. The [status record](../../../../docs/session-format-status.md#finalization-record) therefore requires a complete compatibility checkpoint: accepted record semantics remain locked, while compatible current-source changes proceed through new records and breaking changes require a higher writer. The existing classifier and unknown-event protocol are unchanged. This adds no runtime field and cannot detect behavior changes hidden behind unchanged declarations. Coordinated edits to the in-tree authority and checkpoint still require review.
## Alternatives considered
diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md
index 10dfd0565c..001ce197c4 100644
--- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md
+++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md
@@ -10,21 +10,25 @@ Status: implemented
## 决策
-一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、注释和属性顺序属于摘要之外的展示细节。历史记录保留源码文件路径而不包含行号,因为其选定的源码树与当前工作树不同。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。
+一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、普通注释和属性顺序属于摘要之外的展示细节。历史记录保留源码文件路径而不包含行号,因为其选定的源码树与当前工作树不同。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。显式兼容性标记依照[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)携带纳入指纹的策略数据。
专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
-自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
+自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
回溯性的[发行版本比较](../../../../docs/persistence-changes/releases/README.zh.md)使用独立的 `persistence-release` 类型和固定 tag 清单。它们保留变化后的 schema,并重建相邻版本,包括未变的 tag。早期版本先于这些确认规则,因此其分类只提供信息,观察到的版本号不会为满足当前最低版本要求而被改写。历史解析保留可选的 surface 操作;当前解析器和确认链仍保持严格。这一区分允许近似回填,同时不会将其变成当前变更的授权。
-[格式参考](../../../../docs/persistence-changes/historical-formats/README.zh.md)为低于写入器常量的每个历史整数版本保留完整目录。当前写入器继续使用现有生成目录。覆盖范围由常量推导,而非手工维护的版本清单,因此推进写入器必须归档其前驱。归档命令复制已验证的当前目录,保留从根类型可达的定义,移除源码行号,并拒绝覆盖已有快照。同一格式内可能新增事件或改变可选载荷,因此来源检查点保持明确;这些快照不授权当前变更,也不声称迁移安全。机器 schema 与精确生成的 schema 区间保留历史标识符,当前术语检查仍覆盖周围的人工说明。
+[格式参考](../../../../docs/persistence-changes/historical-formats/README.zh.md)为低于写入器常量的每个历史整数版本保留完整目录。当前写入器继续使用现有生成目录。覆盖范围由常量推导,而非手工维护的版本清单,因此推进写入器必须归档其前驱。归档命令复制已验证的当前目录,保留从根类型可达的定义,移除源码行号,并拒绝覆盖已有快照。同一格式内可能新增事件或改变可选载荷,因此来源检查点保持明确;这些快照不授权当前变更,也不声称迁移安全。机器 schema 与精确生成的 schema 区间保留历史标识符和源码包路径;当前术语与包路径检查仍覆盖周围的人工说明和当前目录。
记录命令从仓库内模板生成双语目录和一致性记录。作者可以将两种语言的概述、兼容性说明和实际验证证据作为结构化输入。生成过程提供标识符、摘要和快照,不编造兼容性说明或测试结果。结构化检查输出保留非零失败退出码,并报告独立于可读描述的稳定变更种类。
-显式更新可刷新尚未接受的末端记录,无需删除其人工说明。它根据剩余历史重新计算转换,并拒绝基线或已有依赖方的记录。审阅接受状态不是目录内可得的事实,因此作者保留已接受记录并添加后继。
+显式更新可刷新尚未接受的末端记录,无需删除其人工说明。它根据剩余历史重新计算转换,并拒绝基线、已有依赖方的记录或已定稿记录。目录不会推断定稿检查点之外的审阅接受状态,因此作者保留已接受记录并添加后继。
+
+### 定稿检查点
+
+定稿独立于发布事实记录接受状态。末端记录不能复用原有头版本递增,继续授权对已写入数据进行破坏性变更。因此,[状态记录](../../../../docs/session-format-status.zh.md#finalization-record)要求完整兼容性检查点:已接受记录的语义保持锁定,当前源码的兼容变更通过新记录继续,破坏性变更要求更高的写入器版本。现有分类器与未知事件协议保持不变。这不会添加运行时字段,也不能检测未变声明后隐藏的行为变化。对目录内真源与检查点的协同修改仍须审阅。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
index 2b32ebfee1..2c5a3f8554 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
-2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09
-2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4
+2026-09-15-shipped-optional-bundles.md: c9f560f5c07ec6fb18030b77a0d1f53bd27dc045
+2026-09-15-shipped-optional-bundles.zh.md: e91b6580e29cd7ae142c9c61ed6aac7e5e1476eb
diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
index 40ef69d388..c9f560f5c0 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
@@ -14,7 +14,7 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
-Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
+The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
## Alternatives considered
@@ -26,4 +26,4 @@ Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile
## Consequences
-Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically.
+Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph.
diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
index adf2b979b9..e91b6580e2 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
@@ -14,7 +14,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
-Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
+[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
## 考虑过的替代方案
@@ -26,4 +26,4 @@ Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-tea
## 影响
-可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。
+可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。
diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml
new file mode 100644
index 0000000000..d05cd07741
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md
+2026-09-16-desktop-release-version-derivation.md: 0f1d917cb0d382453fbb0dca561a1eae7d530d67
+2026-09-16-desktop-release-version-derivation.zh.md: e9576e30232b5eb247de047cb67f51db5fa43ae3
diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md
new file mode 100644
index 0000000000..0f1d917cb0
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md
@@ -0,0 +1,29 @@
+# Agent Note: Derive Desktop test versions from the complete dsh version
+
+Status: implemented
+
+English | [中文](2026-09-16-desktop-release-version-derivation.zh.md)
+
+## Problem
+
+Replacing a dsh prerelease identifier with the update channel name loses the base release identity and changes SemVer ordering. A date alone cannot distinguish multiple test builds of the same base.
+
+## Decision
+
+The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. Final release-family package versions remain equal. The base is retained separately before manifests are retagged, so another test release cannot append a second date suffix.
+
+The fixed Nightly feed is a distribution address, independent of version derivation. Existing clients continue to use that address with prerelease updates enabled and downgrades disabled. Test distribution does not publish the unsuffixed base. Operators check existing release records and objects before assigning an index.
+
+The packaging decision retains runtime/version equality; the installed-update materials decision retains private identities and data isolation. Their Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built.
+
+## Alternatives considered
+
+**Replace alpha, beta, or rc with nightly.** This discards the base prerelease and can sort above its later releases, preventing updates to the intended version line.
+
+**Rename the feed with the prerelease identifier.** Installed clients would continue checking their existing feed and miss the replacement publication.
+
+**Enable downgrade to repair an incorrectly numbered release.** A global downgrade allowance changes ordinary update safety. Affected installations use a manual installer; correcting the feed alone cannot migrate them.
+
+## Consequences
+
+A test build has an unambiguous base and creation date without changing production version identity. Signing and upload still validate the final equal package versions. Installed-update material allocation accepts dated prerelease versions; retained legacy Nightly material remains readable for audit.
diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md
new file mode 100644
index 0000000000..e9576e3023
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 从完整 dsh 版本派生 Desktop 测试版本
+
+Status: implemented
+
+[English](2026-09-16-desktop-release-version-derivation.md) | 中文
+
+## 问题
+
+用更新通道名替换 dsh 预发布标识会丢失基础发布身份,并改变 SemVer 排序。只有日期无法区分同一基础版本的多次测试构建。
+
+## 决策
+
+[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。最终发布家族包版本保持一致。修改清单版本前单独记录基础版本,避免下一次测试发布追加第二个日期后缀。
+
+固定 Nightly feed 是分发地址,与版本派生独立。现有客户端继续使用该地址,允许预发布更新并禁止降级。test 分发不发布无后缀基础版本。操作者分配序号前检查已有发布记录和对象。
+
+打包决策继续规定运行时与包版本一致性;安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。
+
+## 考虑过的替代方案
+
+**用 nightly 替换 alpha、beta 或 rc。** 这会丢弃基础预发布标识,并可能排在其后续版本之上,阻止更新到预期版本序列。
+
+**按预发布标识重命名 feed。** 已安装客户端仍查询原有 feed,将无法获取替换后的发布。
+
+**为修复错误版本号而开启降级。** 全局允许降级会改变常规更新的安全性。受影响安装使用手动安装包;仅修正 feed 无法迁移它们。
+
+## 结果
+
+测试构建具有明确的基础版本和创建日期,production 版本身份保持一致。签名与上传仍验证最终各包版本相等。安装版更新物料分配接受带日期的预发布版本;保留的旧 Nightly 物料仍可读取以供审计。
diff --git a/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.i18n.yaml
new file mode 100644
index 0000000000..45223d0f08
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-persistence-schema-review.md
+2026-09-17-persistence-schema-review.md: 47308a5584b368c99ee849b6d550538ba1856a09
+2026-09-17-persistence-schema-review.zh.md: eaa3c17991b23612753634297993dd3b951259ab
diff --git a/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.md b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.md
new file mode 100644
index 0000000000..47308a5584
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.md
@@ -0,0 +1,39 @@
+# Agent Note: Persistence schema policy and review
+
+Status: implemented
+
+English | [中文](2026-09-17-persistence-schema-review.zh.md)
+
+## Problem
+
+Structural fingerprints identify changed persistence types, but a fingerprint alone cannot explain a shared change or distinguish a declared reader promise from an assumption about runtime behavior. Names derived from union positions and traversal paths also change when unrelated siblings are added, obscuring the fields a reviewer needs to inspect.
+
+## Decision
+
+Inventory format 2 records supported compatibility metadata with the affected property. The metadata names its binding, policy version, literal discriminator, unknown-kind preservation promise, and qualified attribution kinds. Policy-bearing graphs use a separate fingerprint domain; graphs without a bound policy retain format-1 normalization and fingerprints. Inventory versions describe schema tooling, independently of Session format versions.
+
+The extractor accepts explicit source bindings only at the core-owned source declaration and requires a literal user or developer role at each use. Producer annotations qualify complete wire-kind groups. The classifier permits a qualified new attribution kind only when both saved schemas carry matching promises. Existing kinds remain subject to structural comparison; removals, retroactive qualification, reserved identities, and unrelated breaking changes retain strict classification. Runtime owners establish and test the promised preservation behavior before activating a binding.
+
+An optional property with no JSON value is normally omitted from the extracted graph. `@persistenceReserved` explicitly retains such a property as optional `never` when a reader forbids its name. A later JSON-valued definition then changes an existing type instead of appearing as an additive optional field. The marker accepts only one argument-free annotation on an optional `never` or `undefined` property and rejects a mapped use that makes the property required or gives it a JSON value.
+
+Current inventories retain authored names and source locations as descriptive metadata. Structural digests identify types and provide catalog anchors. Catalogs group source alternatives by literal `kind` and retain every `form` alternative, required field, index signature, and link to its complete definition. Bounded array labels describe their elements instead of inheriting the first event that reached the array. Historical references retain their frozen rendering.
+
+The read-only `persistence-review` command accepts explicit before and after inventories. It matches unchanged schemas by digest and changed alternatives only when literal discriminators identify them uniquely. Ambiguous alternatives remain separate additions and removals. Shared structural evidence lists every affected root, while a separate section preserves the authoritative classifier diagnostics. The [history mechanism](2026-09-11-persistence-type-history.md) continues to own acknowledgement and version requirements.
+
+## Alternatives considered
+
+**Ignore all source changes.** A source can carry replay or validation meaning. Explicit, fingerprinted opt-ins constrain the supported exception and leave other changes visible.
+
+**Use traversal paths as type identities.** Union positions and first-owner paths describe one walk through a graph. Unrelated insertions and shared references can change them without changing the persisted type.
+
+**Infer renames from similar fields.** Several alternatives can have the same discriminator or similar structure. Keeping ambiguous additions and removals avoids presenting an unsupported correspondence as fact.
+
+**Replace compatibility checks with a readable diff.** Review explanations group evidence for a human reader. They cannot relax the classifier or replace the in-tree acknowledgement history.
+
+**Retain every optional-never property.** Unmarked optional-never properties already normalize away in recorded schemas. Changing that rule would alter unrelated fingerprints and historical normalization; explicit reservations retain only the reader-owned prohibition.
+
+## Consequences
+
+Declared compatibility promises participate in fingerprints and remain available when comparing historical snapshots. The tooling does not infer or enforce runtime preservation; the owner of an activated binding supplies that behavior. Ordinary comments, labels, and source locations remain outside structural identity.
+
+Complete nested schema graphs remain stored in each inventory. Stable labels and grouped review output reduce presentation noise without changing that storage encoding or rewriting frozen historical records. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) describes the review command and acknowledgement workflow.
diff --git a/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.zh.md b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.zh.md
new file mode 100644
index 0000000000..eaa3c17991
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-persistence-schema-review.zh.md
@@ -0,0 +1,39 @@
+# Agent Note: 持久化 schema 策略与审阅
+
+Status: implemented
+
+[English](2026-09-17-persistence-schema-review.md) | 中文
+
+## 问题
+
+结构指纹能标识变化的持久化类型,但单凭指纹无法解释共享变更,也无法区分已声明的读取承诺与对运行时行为的假设。由联合类型位置和遍历路径生成的名称还会因无关分支新增而变化,遮蔽审阅者需要检查的字段。
+
+## 决策
+
+Inventory format 2 在受影响属性上记录受支持的兼容性元数据。元数据包含绑定、策略版本、字面量判别字段、未知 kind 保留承诺和符合条件的归属 kind。带策略的图使用独立指纹域;没有绑定策略的图保留 format-1 规范化和指纹。Inventory 版本描述 schema 工具,独立于 Session 格式版本。
+
+提取器仅接受核心拥有的 source 声明上的显式绑定,并要求每个使用位置具有 user 或 developer 字面量角色。生产者标记对完整 wire-kind 分组赋予资格。仅当双方保存的 schema 携带匹配承诺时,分类器才允许新增符合条件的归属 kind。已有 kind 仍须进行结构比较;删除、追认资格、保留标识和无关破坏性变更继续采用严格分类。运行时所有者在激活绑定前实现并验证承诺的保留行为。
+
+没有 JSON 值的可选属性通常从提取图中省略。当读取器禁止某个属性名时,`@persistenceReserved` 将该属性显式保留为可选 `never`。后续允许 JSON 值的定义因此属于已有类型变更,而非新增可选字段。标记仅接受可选 `never` 或 `undefined` 属性上的单个无参数注解,并拒绝将属性变为必选或允许 JSON 值的映射类型使用。
+
+当前 inventory 将人工声明名称和源码位置保留为描述性元数据。结构摘要标识类型并提供目录锚点。目录按字面量 `kind` 对消息源分支分组,并保留每个 `form` 分支、必需字段、索引签名及指向完整定义的链接。有界数组标签描述元素,不继承最先到达该数组的事件名称。历史参考保留冻结的渲染方式。
+
+只读 `persistence-review` 命令接受显式的变更前后 inventory。它按摘要匹配未变 schema,仅当字面量判别字段唯一标识分支时才匹配变化分支。有歧义的分支保留为独立新增与删除。共享结构证据列出每个受影响根,独立章节保留权威分类器诊断。[历史机制](2026-09-11-persistence-type-history.zh.md)继续负责确认和版本要求。
+
+## 考虑过的替代方案
+
+**忽略所有 source 变更。** 消息源可能承载回放或校验语义。显式且纳入指纹的选择加入约束受支持的例外,并使其他变更保持可见。
+
+**将遍历路径用作类型身份。** 联合类型位置和首个引用者路径描述的是一次图遍历。无关插入和共享引用可能改变它们,而持久化类型并未改变。
+
+**从相似字段推断重命名。** 多个分支可能具有相同判别值或相似结构。保留有歧义的新增与删除,避免将缺乏依据的对应关系呈现为事实。
+
+**用可读 diff 取代兼容性检查。** 审阅说明为读者归并证据,不能放宽分类器,也不能取代目录内的确认历史。
+
+**保留每个 optional-never 属性。** 未标记的 optional-never 属性在已记录 schema 中已经被规范化省略。改变该规则会影响无关指纹和历史规范化;显式保留仅记录读取器拥有的禁止规则。
+
+## 影响
+
+声明的兼容性承诺参与指纹,并在比较历史快照时保持可用。工具不会推断或强制运行时保留行为;激活绑定的所有者提供该行为。普通注释、标签和源码位置仍处于结构身份之外。
+
+每份 inventory 继续存储完整嵌套 schema 图。稳定标签和分组审阅输出减少展示噪音,不改变存储编码,也不重写冻结历史记录。[实操手册](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)介绍审阅命令和确认流程。
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml
new file mode 100644
index 0000000000..b2f71a9f4d
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md
+2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9
+2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md
new file mode 100644
index 0000000000..911e027719
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md
@@ -0,0 +1,47 @@
+# Agent Note: Windows runtime signature cache
+
+Status: implemented
+
+English | [中文](2026-09-17-windows-runtime-signature-cache.zh.md)
+
+## Problem
+
+Windows packaging reconstructs unsigned native dependencies on each build. Signing unchanged Python and LibreOffice files repeats hardware-token and timestamp operations. Starting PowerShell for each public-key inspection also repeats process initialization.
+
+## Decision
+
+Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide.
+
+Share the cache only within the current Windows account. Require a fixed local drive, current-account ownership and a private destination DACL; reject linked or redirected paths. Programs running as that account remain trusted. Explicit migration copies complete integrity-checked entries without modifying its source or replacing an existing valid entry. Different timestamp bytes at the same input/policy key do not require replacement; every actual restoration still verifies Windows trust.
+
+The default cache lives beside the account's existing signing state under the user profile. AppData is unsuitable for this default because an MSIX launcher can redirect new files into its private package storage, separating otherwise identical worktrees launched from different programs. A validated explicit directory remains available for isolated build storage.
+
+Serialize each complete signing stage and cache-maintenance operation with an exclusive Windows file handle. The lock covers preflight, primary-runtime signing, application-runtime signing and artifact creation separately, leaving compilation and preparation concurrent. A waiter rechecks the cache after acquisition. Stage ownership and the retained hardware-attempt interlock have different lifetimes: closing the stage handle releases contention, while hardware failures and interruptions retain the existing attempt evidence and never authorize an automatic retry.
+
+Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
+
+The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
+
+## Alternatives considered
+
+**Multi-file hardware signing.** The primary-runtime decision records that SignTool can continue after the first file fails. Reducing repeated hardware work through caching preserves the existing stop behavior.
+
+**Detached signature extraction and attachment.** Complete-file caching avoids adding PE-signature manipulation to packaging. Its additional disk use is measurable and confined to build storage.
+
+**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes.
+
+**Per-entry locks only.** They avoid duplicate signatures for one key but do not coordinate different files, preflight or installer signing against the same hardware token. Stage-wide ownership covers those calls without holding a lock during unrelated compilation.
+
+**Cache-age expiry.** Age alone does not establish that a timestamped signature is invalid. Rechecking current Windows trust preserves the validation requirement without periodically repeating unchanged hardware signatures.
+
+## Consequences
+
+Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files.
+
+Complete signed Windows x64 builds on one host measure an empty shared cache and reuse from a separate fresh worktree: hardware calls fall from 379 to 17, elapsed time from 37:03 to 13:45, and all 362 runtime entries hit. Cache restoration takes 132 seconds, including 130 seconds of current trust verification. Both builds pass final runtime smoke and signed-artifact checks. Downloads are prepared before timing; each scenario is one complete run. A separate controlled native-file change produces miss/hit/miss with two hardware calls; it tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate.
+
+## Risks
+
+The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight.
+
+No automatic capacity eviction runs. Explicit maintenance waits for stage ownership and retires complete entries before deleting their files; interrupted staging directories remain distinguishable from published entries. Runtime summaries count actual publications separately from retained collisions, and report cache verification time separately from signing time. They do not measure the entire package's verification cost.
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md
new file mode 100644
index 0000000000..bb8cdd6bdc
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md
@@ -0,0 +1,47 @@
+# Agent Note: Windows 运行时签名缓存
+
+Status: implemented
+
+[English](2026-09-17-windows-runtime-signature-cache.md) | 中文
+
+## Problem
+
+Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 Python 和 LibreOffice 文件签名会重复执行硬件令牌操作和时间戳请求。每个公钥验签任务单独启动 PowerShell,也会重复进程初始化。
+
+## Decision
+
+按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。
+
+缓存仅在当前 Windows 账户内共享。要求固定本地磁盘、当前账户所有权及私有目标目录 DACL;拒绝带链接或被重定向的路径。同账户运行的程序仍受信任。显式迁移复制通过完整性检查的完整缓存项,不修改源目录,也不替换已有有效项。同一输入和策略键下的时间戳字节不同,不构成替换理由;每次实际恢复仍验证 Windows 信任。
+
+默认缓存位于用户目录下,与账户已有签名状态放在一起。AppData 不适合作为此默认位置,因为 MSIX 启动器可能将新文件重定向到私有包存储,使不同程序启动的相同 worktree 使用不同缓存。独立构建存储仍可指定经过验证的显式目录。
+
+通过 Windows 独占文件句柄串行执行每个完整签名阶段及缓存维护操作。锁分别覆盖预检、主要运行时签名、应用运行时签名及产物生成,编译和准备仍可并行。等待者在获取锁后重新检查缓存。阶段所有权与持久保留的硬件尝试互锁具有不同生命周期:关闭阶段句柄释放竞争锁,而硬件失败或中断保留现有尝试证据,绝不构成自动重试授权。
+
+公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
+
+[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
+
+## Alternatives considered
+
+**多文件硬件签名。** 主要运行时决策记录了 SignTool 在首个文件失败后仍继续处理的行为。通过缓存减少重复硬件操作,可以保留现有停止语义。
+
+**分离签名的提取与附加。** 缓存完整文件可以避免给打包流程新增 PE 签名处理代码。其额外磁盘占用可测量,且限于构建存储。
+
+**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。
+
+**仅使用逐项锁。** 它能避免同一键的重复签名,却无法协调不同文件、预检或安装器签名对同一硬件令牌的访问。阶段级所有权覆盖这些调用,不在无关编译期间持锁。
+
+**按缓存年龄过期。** 年龄本身不能说明带时间戳的签名无效。重新检查当前 Windows 信任可以保留验证要求,同时避免周期性重复未变化的硬件签名。
+
+## Consequences
+
+原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。
+
+同一主机上的完整 Windows x64 签名构建测量空共享缓存与另一个全新 worktree 的复用:硬件调用从 379 次降至 17 次,耗时从 37:03 降至 13:45,362 个运行时缓存项全部命中。缓存恢复耗时 132 秒,其中当前信任验证占 130 秒。两轮均通过最终运行时 smoke 及签名产物检查。下载内容在计时前准备好;每种场景均为一次完整运行。另一次受控原生文件变更产生未命中、命中、未命中的结果,合计两次硬件调用;它验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。
+
+## Risks
+
+缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。
+
+不执行自动容量淘汰。显式维护等待阶段所有权,在删除文件前先将完整缓存项移出已发布名称;中断的暂存目录仍可与已发布项区分。运行时摘要区分实际发布和冲突保留,并将缓存验证耗时与签名耗时分别记录;它不测量整个安装包的验证成本。
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml
new file mode 100644
index 0000000000..c8a392f733
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-signature-completion.md
+2026-09-17-windows-signature-completion.md: e9c81531633a41c87ea10c9e4af2999923aa4470
+2026-09-17-windows-signature-completion.zh.md: 3142749db309b638676c171d4b1da8bd34729fe4
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md
new file mode 100644
index 0000000000..e9c8153163
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md
@@ -0,0 +1,35 @@
+# Agent Note: Separate Windows hardware signing from timestamp completion
+
+Status: implemented
+
+English | [中文](2026-09-17-windows-signature-completion.zh.md)
+
+## Problem
+
+A combined SignTool command reports timestamp failure as signing failure. Retrying it repeats hardware access, while treating every timestamp error as an uncertain private-key outcome requires administrator recovery. Public timestamp-only requests can fail independently of the token.
+
+## Decision
+
+Sign a private PE copy once without a timestamp. Release the hardware interlock only after successful execution and Windows verification of the configured primary signature. Hardware or primary-verification failures retain the interlock; no path automatically recovers it. Appended signatures are unsupported.
+
+Timestamp fresh copies of the frozen signed file without signing credentials. Only normal failure or warning exits permit another attempt: at most three calls, with one- and two-second delays. Launch failures, uncertain termination, storage changes and verification failures stop immediately. The preflight's overall 60-second deadline includes these operations and does not guarantee three full subprocess deadlines.
+
+Require Windows trust, timestamp and certificate identity. Remove unsigned signature attributes from private copies with the configured SignTool and compare their complete normalized bytes. A removal warning is accepted only when all bytes remain unchanged. Certificate equality alone cannot exclude different content signed by the same certificate; full-file equality avoids a custom PE or ASN.1 canonicalizer.
+
+SignTool uses short filenames with the original extension in a private temporary directory. Its longest generated path is checked before hardware access, and the journal records the original-to-staged mapping. The target may occupy another volume: copy verified bytes into a private directory beside it, compare hashes, recheck the original and rename within that volume. Verify committed bytes afterward. The build account must own trusted storage and exclusive target access.
+
+Exhausted timestamp attempts stop packaging and queued work without another hardware call. Failure evidence is copied to the supervised run before staging cleanup; evidence or audit failure preserves staging. Final artifact verification and release completion remain mandatory. The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) owns runtime contents and vendor signatures.
+
+## Alternatives considered
+
+**Retry combined signing or classify diagnostic text.** Exit codes and localized text cannot establish the private-key outcome. Separate operations avoid repeated hardware access.
+
+**Reuse failed timestamp output.** A failed process may partially modify its file. Every attempt starts with identical verified primary-signature bytes.
+
+**Stage beside each target for all operations.** Nested dependencies can exceed SignTool's path limit even when the original fits. Short tool paths and same-volume publication address separate requirements.
+
+## Consequences
+
+Fault tests cover lock retention, isolated timestamp retries, queue rejection, credential exclusion, substituted content, changed targets, long paths and evidence failures. Public-only qualification covers EXE, DLL, PYD and NODE files, including failure followed by success and cross-volume publication. A real supervised signing preflight has passed; complete installer comparisons remain separate qualification. This decision does not identify or repair the timestamp service's network failures.
+
+DigiCert [supports separate signing and timestamping](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems). Microsoft documents [SignTool commands and exit codes](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). The configured tool and normalization implementation participate in signature-cache identity.
diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md
new file mode 100644
index 0000000000..3142749db3
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md
@@ -0,0 +1,35 @@
+# Agent Note:分离 Windows 硬件签名与时间戳处理
+
+Status: implemented
+
+[English](2026-09-17-windows-signature-completion.md) | 中文
+
+## 问题
+
+合并的 SignTool 命令把时间戳失败报告为签名失败。重试会重复访问硬件,而把所有时间戳错误都视为私钥操作结果不确定,又需要管理员恢复。公开时间戳请求可以在不使用令牌的情况下独立失败。
+
+## 决策
+
+对私有 PE 副本仅签名一次,不请求时间戳。只有命令成功且 Windows 验证配置证书的主签名后,才释放硬件互锁。硬件或主签名验证失败保留互锁;任何路径都不自动恢复互锁。不支持追加签名。
+
+从固定的已签名文件生成新副本并请求时间戳,不传递签名凭据。只有正常退出但返回失败或警告时才允许再次尝试:最多调用三次,间隔为一秒和两秒。启动失败、终止状态不确定、存储变化和验签失败会立即停止。预检整体 60 秒期限包含这些操作,不保证每次子进程都能获得完整期限。
+
+要求 Windows 信任、时间戳和证书身份均通过验证。使用配置的 SignTool 删除私有副本的未签名属性,再比较完整的规范化字节。删除警告仅在所有字节未变时接受。仅比较证书无法排除由同一证书签署的不同内容;全文件相等检查避免自行实现 PE 或 ASN.1 规范化器。
+
+SignTool 在私有临时目录中使用保留原扩展名的短文件名。访问硬件前检查生成的最长路径,日志记录原文件与暂存文件的对应关系。目标可以位于另一个卷:将已验证字节复制到目标旁的私有目录,比较摘要,重新检查原文件,再在同一卷内重命名。随后验证提交后的字节。构建账户必须拥有受信任的存储和目标独占访问权。
+
+时间戳尝试耗尽后,停止打包和排队任务,不再次调用硬件。清理暂存目录前,将失败证据复制到受监督运行目录;证据保存或审计失败时保留暂存目录。最终产物验签和发布完成检查仍是必需步骤。[第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)负责运行时内容与上游签名。
+
+## 考虑过的替代方案
+
+**重试合并签名命令或按诊断文本分类。** 退出码和本地化文本无法确定私钥操作结果。分离操作避免重复访问硬件。
+
+**复用失败的时间戳输出。** 失败进程可能已部分修改文件。每次尝试均从相同的已验证主签名字节开始。
+
+**所有操作都在目标旁暂存。** 即使原路径未超限,嵌套依赖的暂存路径也可能超出 SignTool 限制。短工具路径和同卷发布分别满足不同要求。
+
+## 影响
+
+故障测试覆盖互锁保留、独立时间戳重试、队列拒绝、凭据隔离、内容替换、目标变化、长路径和证据保存失败。仅使用公开操作的验收覆盖 EXE、DLL、PYD 和 NODE 文件,包括先失败后成功及跨卷发布。真实受监督签名预检已通过;完整安装包对比仍需单独验收。此决策不确定或修复时间戳服务的网络故障。
+
+DigiCert [支持分开签名与添加时间戳](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems)。Microsoft 记录了 [SignTool 命令及退出码](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool)。配置的工具和规范化实现均参与签名缓存身份计算。
diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml
new file mode 100644
index 0000000000..be42dfe317
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md
+2026-09-19-evidence-led-simplification-surveys.md: c217f80239690ee2f7310ca8e97956ec06fb75c7
+2026-09-19-evidence-led-simplification-surveys.zh.md: 1b1a11731967c87ab54aa9eba82253f90bb2abde
diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md
new file mode 100644
index 0000000000..c217f80239
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md
@@ -0,0 +1,66 @@
+# Agent Note: Evidence-led simplification surveys
+
+Status: implemented
+
+English | [中文](2026-09-19-evidence-led-simplification-surveys.zh.md)
+
+## Problem
+
+Unused-symbol searches can miss working behavior whose implementation costs more than its required outcome, and can misclassify public extension APIs without fixed repository callers as dead. The previous simplification skill also combined discovery with lengthy archive procedures, omitted application and Python paths from its consumer examples, and suggested a proposal outline without the mandatory `Alternatives considered` section.
+
+## Decision
+
+The [simplification skill](../../../skills/dsh-find-simplifications/SKILL.md) traces a complete producer-to-outcome path, asks which distinctions change consumer actions, considers explicit reductions in supported behavior, and searches for maintained copies that an authoritative read can replace. It separates composition availability from deployment policy and counts removed configuration, lifecycle, tests, and documentation together with source code.
+
+Production use changes the required trade-off evidence; it does not automatically veto a proposal or authorize implementation. Dynamic plugin discovery, installed consumers, generated runtime assets, profiles, applications, and Python participate in consumer tracing. Protected adapters and persistence designs, released data, trust boundaries, independent invariant observations, synchronous publication, cancellation, and quiescent disposal remain constraints.
+
+The entry point separates decision criteria from an [optional historical reference](../../../skills/dsh-find-simplifications/references/historical-patterns.md) pairing mechanisms with counterexamples and residual obligations. Archive mechanics remain at their existing owner, and the proposal skeleton uses the canonical headings.
+
+## Historical evidence
+
+The author sampled 26 of the 100 English implemented or archived simplification notes: 11 implemented and 15 archived. All nine rejected simplification notes supplied counterexamples. The sample covers producerless variants, inert request knobs, explicit inputs, public projections, derived data, shared composition, lifecycle ownership, and dependency replacements; it is purposeful, not random or exhaustive.
+
+The frozen comparison inputs contained 2,222 whitespace-delimited words in the baseline entry, 1,372 in the revised entry, and 897 in its reference; the hashes below identify those measured artifacts. The reference teaches questions rather than current deletion instructions. An adapter-rejected knob differs from a working extension API; a simpler public status can retain internal residency distinctions; reading at feedback time changes the observation promise; replacing a dependency can lose deterministic clocks or necessary teardown. Archived records were read without following or repairing outbound links. No old note was edited, archived, or deleted.
+
+## Bounded survey evidence
+
+The source snapshot is master after #4618. Each arm uses fresh agents with the same inherited model and reasoning effort, identical owner domains, a time cap, and at most four submissions per domain. Other outputs remain hidden from discovery agents. The runtime domain covers `core`, `session`, `session-query`, `api`, `sdk`, and Python; execution covers `shell`, `subprocess`, `terminal`, `jobs`, `fs`, `mcp`, and `boot`.
+
+A third matched domain, covering `llm`, `compaction`, `context`, `skill`, and `extensions`, was added after the initial runtime results to broaden coverage. It is exploratory, uses a shorter equal cap, and remains separate from the initially declared two-domain comparison. The study stops after these three domains.
+
+The skill author derived the revision from historical notes while isolated from current candidates. The coordinator received accidental early baseline hints, but accepted the independent author's revision unchanged before revised runs. Candidate packets hid arm labels from a separate reviewer; the coordinator's integration was not blind. The frozen inputs had SHA-256 values `9830af2272a1a1c3832078f31a432c8abe0335a013bc6c248c072b388520e798` for the baseline entry, `3b101d16ae098855c9214ad7fed631a2f079b6078c83b49829ae87305f631a87` for the revised entry, and `9a6b674d1eeadb691481ea09838b211b8e94c94e5a8cd928726246b1c097b2b5` for its reference.
+
+After these runs, review replaced only the skill phrase “earns its place” with “is justified”; no discovery criterion changed and no new discovery run was performed. Each prompt requested exact source/search evidence, producer and consumer classification, note overlap, net reduction, the strongest counterargument, and acceptance requirements. Review applied those same criteria to every submission, distinguished local edits from durable decisions, and did not treat either public discoverability or a line-deletion count as a verdict by itself.
+
+| Version | Domain | Cap | Submitted | Durable as submitted | Durable after refinement | Local | Deferred | Elapsed |
+|---|---|---|---:|---:|---:|---:|---:|---|
+| Baseline | Runtime | 12m | 3 | 1 | 2 | 0 | 0 | 6m 30s |
+| Baseline | Execution | 12m | 3 | 3 | 0 | 0 | 0 | 8m 40s |
+| Revised | Runtime | 12m | 2 | 1 | 0 | 1 | 0 | 5m 05s |
+| Revised | Execution | 12m | 3 | 2 | 0 | 1 | 0 | 8m 19s |
+| Baseline | Model/context, exploratory | 10m | 2 | 2 | 0 | 0 | 0 | 6m 02s |
+| Revised | Model/context, exploratory | 10m | 2 | 0 | 0 | 1 | 1 | 7m 51s |
+
+The baseline yields six durable proposals as submitted and two after refinement. The revised skill yields three durable proposals and three local cleanups, with one proposed behavior reduction deferred. Python RPC narrowing and filesystem-invariant omission overlap. The combined set contains nine distinct durable proposals; the revised version adds [configuration-only HMR](../../proposed/simplification/2026-09-19-config-only-hmr.md). This sample does not show higher durable-proposal yield from the revision.
+
+Review narrowed the [query API proposal](../../proposed/simplification/2026-09-19-trim-session-query-convenience-api.md) to retain `filterEvents`, whose literal substring behavior differs from FTS. The [prompt-event proposal](../../proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md) explicitly retires an active extension promise instead of claiming per-step assembly makes notifications redundant. Both refined baseline findings remain useful and are retained.
+
+The three revised-only local findings were a mirrored provider reference, copied filesystem results, and duplicate assembler ordering state. They were counted separately and were not written as active design proposals.
+
+Review deferred replacing automatic Session-reference sizing with a fixed byte default. The [current budget decision](../bug-fix/2026-09-05-session-reference-model-budget.md) fixes lost useful context on large models; best-effort spill retrieval does not replace inline context. No new workload evidence justified restoring the problematic default. The proposal's deletion inventory was accurate, but that alone did not overcome the current requirement.
+
+## Alternatives considered
+
+**Keep caller counts as the main filter.** This misses producer support, dynamic consumers, and useful explicit behavior reductions. More symbol matches do not establish which obligations can disappear.
+
+**Encode historical successes as a deletion checklist.** Architecture and consumers change. The reference uses mechanisms and counterexamples without making frozen records current authority.
+
+**Select the revision by proposal count.** The observed count does not support superiority. Its demonstrated benefit is complementary discovery and explicit classification; the stronger union includes findings from both versions.
+
+## Consequences
+
+The skill changes survey guidance, not runtime behavior. The nine proposals still require scoped implementation and relevant tests; their future implementation checks were not run. Three local opportunities remain evaluation findings, and the unsupported budget reversal is excluded from the proposal set.
+
+There is one discovery run per version/domain, no randomized order or repetition, unequal actual elapsed time within equal caps, and subjective review. Source reductions are estimates, not implemented diffs. These results establish neither statistical superiority, reproducible speedup, nor exhaustive coverage.
+
+The [dependency](2026-07-26-dependencies-over-hand-rolling.md), [prose](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md), [archive](2026-07-26-frozen-agent-note-archive.md), and [performance-workflow](2026-09-06-evidence-driven-performance-skill.md) decisions retain their independent responsibilities. This note adds simplification-specific discovery and evaluation criteria without superseding those owners.
diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md
new file mode 100644
index 0000000000..1b1a117319
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md
@@ -0,0 +1,66 @@
+# Agent Note: 以证据指导简化调研
+
+Status: implemented
+
+[English](2026-09-19-evidence-led-simplification-surveys.md) | 中文
+
+## 问题
+
+仅搜索未使用的符号,可能漏掉实现成本超出必要结果的有效行为,也可能将没有固定仓库调用方的公共扩展 API 误判为死代码。原简化 skill(技能)还将发现候选与冗长的归档流程混在一起,消费方示例遗漏应用与 Python 路径,建议的提案结构也缺少必需的 `Alternatives considered` 章节。
+
+## 决策
+
+[简化 skill](../../../skills/dsh-find-simplifications/SKILL.md) 追踪从生产方到结果的完整路径,判断哪些区分会改变消费方动作,考虑明确收缩受支持行为,并寻找可以用权威读取替代的持续维护副本。它区分组合可用性与部署策略,并在源码之外合计删除的配置、生命周期、测试和文档成本。
+
+生产使用会改变所需的取舍证据,但不会自动否决提案,也不构成实施授权。动态插件发现、已安装消费方、生成的运行时产物、profile、应用和 Python 都纳入消费方追踪。受保护的适配器与持久化设计、已发布数据、信任边界、独立 invariant 观察、同步发布、取消及达到静止状态的释放仍是约束。
+
+入口将判断标准与[可选历史参考页](../../../skills/dsh-find-simplifications/references/historical-patterns.md) 分开,后者将机制与反例、残留义务配对。归档操作仍由原有归属处负责,提案结构使用规范标题。
+
+## 历史证据
+
+作者从 100 份英文已实现或归档简化记录中抽样 26 份:11 份已实现、15 份归档。全部九份已否决简化记录提供反例。样本涵盖无生产方变体、无效请求开关、显式输入、公共投影、派生数据、共享组合、生命周期所有权和依赖替换;这是有目的的抽样,并非随机或穷尽审查。
+
+冻结的比较输入按空白分隔计数:基线入口为 2,222 词,新版入口为 1,372 词,参考页为 897 词;下方哈希标识这些已测量产物。参考页提供问题,而非当前删除指令。所有适配器都拒绝的开关不同于有效扩展 API;更简单的公共状态可以保留内部驻留差别;反馈时读取会改变观察承诺;依赖替换可能丢失确定性时钟或必要的释放行为。阅读归档记录时未追踪或修复其出站链接。没有编辑、归档或删除任何旧记录。
+
+## 有界调研证据
+
+源码快照固定为 #4618 之后的 master。每组使用继承相同模型和推理强度的全新 agent(智能体),获得相同归属领域、时间上限及每域最多四个提交项。发现候选的 agent 不能读取其他输出。运行时域覆盖 `core`、`session`、`session-query`、`api`、`sdk` 和 Python;执行域覆盖 `shell`、`subprocess`、`terminal`、`jobs`、`fs`、`mcp` 和 `boot`。
+
+初始运行时结果产生后,增加第三个配对领域,覆盖 `llm`、`compaction`、`context`、`skill` 和 `extensions`,以扩大覆盖。该领域属于探索性补充,采用较短但双方相同的时间上限,与最初声明的两域比较分开。调研在这三个领域后结束。
+
+skill 作者与当前候选隔离,根据历史记录撰写修订。协调者意外提前收到少量基线提示,但在新版运行前原样接受独立作者的修订。交给独立审查方的候选材料隐藏了分组标签;协调者整合并非盲审。冻结输入的 SHA-256 为:基线入口 `9830af2272a1a1c3832078f31a432c8abe0335a013bc6c248c072b388520e798`,新版入口 `3b101d16ae098855c9214ad7fed631a2f079b6078c83b49829ae87305f631a87`,新版参考页 `9a6b674d1eeadb691481ea09838b211b8e94c94e5a8cd928726246b1c097b2b5`。
+
+这些运行结束后,评审仅将 skill 中的「earns its place」改为「is justified」;没有改变发现标准,也没有重新运行发现测试。每份提示都要求精确源码/搜索证据、生产方与消费方分类、记录重叠、净减少量、最强反对理由和验收要求。审查对每个提交项使用相同标准,区分局部编辑与长期决策,既不单凭公共可发现性,也不单凭删除行数作结论。
+
+| 版本 | 领域 | 上限 | 提交数 | 原样可用的长期提案 | 调整后可用的长期提案 | 局部项 | 暂缓 | 耗时 |
+|---|---|---|---:|---:|---:|---:|---:|---|
+| 基线 | 运行时 | 12m | 3 | 1 | 2 | 0 | 0 | 6m 30s |
+| 基线 | 执行 | 12m | 3 | 3 | 0 | 0 | 0 | 8m 40s |
+| 新版 | 运行时 | 12m | 2 | 1 | 0 | 1 | 0 | 5m 05s |
+| 新版 | 执行 | 12m | 3 | 2 | 0 | 1 | 0 | 8m 19s |
+| 基线 | 模型/上下文,探索性 | 10m | 2 | 2 | 0 | 0 | 0 | 6m 02s |
+| 新版 | 模型/上下文,探索性 | 10m | 2 | 0 | 0 | 1 | 1 | 7m 51s |
+
+基线产生六个原样可用的长期提案,另有两个调整后可用。新版 skill 产生三个长期提案和三个局部清理项,一项行为收缩被暂缓。Python RPC 收缩和文件系统 invariant 省略相互重叠。合并集合包含九个互不重复的长期提案;新版增加了[仅保留配置更新的 HMR](../../proposed/simplification/2026-09-19-config-only-hmr.zh.md)。该样本未显示新版的长期提案产出更高。
+
+审查缩小了[查询 API 提案](../../proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md),保留其字面子串语义不同于 FTS 的 `filterEvents`。[提示事件提案](../../proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md) 明确撤回当前扩展承诺,而非声称逐步骤组装让通知多余。这两项经调整的基线发现仍有价值,因此保留。
+
+三个仅新版发现的局部项分别是镜像的提供方引用、复制的文件系统结果,以及重复的组装器顺序状态。它们单独计数,没有写成当前设计提案。
+
+审查暂缓了将自动 Session 引用大小改为固定字节默认值的提案。[当前预算决策](../bug-fix/2026-09-05-session-reference-model-budget.zh.md) 修复了大模型丢失有用上下文的问题;尽力而为的 spill 检索不能替代内联上下文。没有新的工作负载证据支持恢复有问题的默认值。提案的删除清单准确,但仅此不足以推翻当前要求。
+
+## 考虑过的替代方案
+
+**继续以调用数量为主要筛选依据。** 这会遗漏生产方支持、动态消费方及有价值的明确行为收缩。更多符号匹配不能证明哪些义务可以删除。
+
+**将历史成功案例编码为删除清单。** 架构与消费方会变化。参考页使用机制与反例,不将冻结记录视为当前权威。
+
+**按提案数量选择修订。** 观察到的数量不支持优越性结论。已展示的收益是互补发现和明确分类;更有价值的合并集合包含两个版本的发现。
+
+## 后果
+
+skill 改变调研指导,不改变运行时行为。九份提案仍需限定范围的实现与相关测试;本次未运行其未来实施检查。三个局部机会保留为评估发现,缺少依据的预算逆转排除在提案集合之外。
+
+每个版本/领域只有一次发现运行,没有随机顺序或重复试验,相同上限内的实际耗时不等,审查带有判断性。源码减少量是估算,并非实现 diff。这些结果不能证明统计优越性、可复现的提速或穷尽覆盖。
+
+[依赖](2026-07-26-dependencies-over-hand-rolling.zh.md)、[措辞](2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md)、[归档](2026-07-26-frozen-agent-note-archive.zh.md)和[性能工作流](2026-09-06-evidence-driven-performance-skill.zh.md)决策保留各自独立职责。本记录增加简化专用的发现与评估标准,不取代这些归属处。
diff --git a/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.i18n.yaml b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.i18n.yaml
new file mode 100644
index 0000000000..29002959bc
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-19-no-unknown-casts.md
+2026-09-19-no-unknown-casts.md: 2f90422d0980fddc2569951ef7f8dba34b488ce4
+2026-09-19-no-unknown-casts.zh.md: 3b66236e586a60a61cdcc1f93b1c0c14ce27eb15
diff --git a/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md
new file mode 100644
index 0000000000..2f90422d09
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md
@@ -0,0 +1,35 @@
+# Agent Note: Prohibit new assertions to unknown
+
+Status: implemented
+
+English | [中文](2026-09-19-no-unknown-casts.zh.md)
+
+## Problem
+
+An assertion through `unknown` removes TypeScript's compatibility check between the original value and a subsequent asserted type. Production code can hide an incorrect interface, and tests can claim that an incomplete fixture satisfies a service. Existing uses also include legitimate widening of untyped parser results, which can use explicit `unknown` declarations instead.
+
+The repository contains assertions across many independent package and test owners. Removing all of them in one change would combine the enforcement decision with unrelated type and fixture redesigns, making the behavior of each replacement harder to review.
+
+## Decision
+
+New direct assertions to `unknown` are prohibited in first-party JavaScript and TypeScript, including tests, fixtures, benchmarks, and scripts. Both `as unknown` and `` syntax are covered, including parenthesized target types and union targets with a direct `unknown` member. An `unknown` declaration remains valid: external data can enter as `const value: unknown = JSON.parse(text)` and reach its declared parser before use. Typed in-process values retain the existing rule against unnecessary runtime validation.
+
+The [checker](../../../../scripts/verify-no-unknown-casts.ts), exposed as `pnpm run verify-no-unknown-casts`, uses the TypeScript syntax tree. It scans tracked source files and non-ignored untracked source files; vendored sources and frozen archived Agent Notes retain their ownership exclusions. The checker ignores comments, strings, and declarations of `unknown`.
+
+Existing assertions have an exact baseline keyed by repository-relative path, a SHA-256 digest of the assertion's syntax tokens, and occurrence count. Baseline edits may only delete entries or decrease counts; reviewers must reject added files, new fingerprints, and increased counts. New or changed assertions outside the baseline and stale baseline entries fail the check; a file-wide allowance or a count budget cannot authorize replacement debt. `--prune` mechanically removes entries or reduces counts and refuses to write while new violations exist. There is no command that adds new exceptions.
+
+The checker participates in CI static and primary modes and the local `hygiene` and `check-all` profiles in [the gate runner](../../../../scripts/run-gates.ts). Existing casts remain visible debt for their owners to remove with suitable type, parser, or fixture changes.
+
+## Alternatives considered
+
+**Remove every existing assertion before enforcing the rule.** Rejected because the cleanup spans unrelated services, wire parsers, and test doubles. Exact existing exceptions let enforcement begin without treating a bulk substitution as a type-safety improvement.
+
+**Allow whole files or a repository-wide count.** Rejected because either permits a new assertion to replace a removed one. The baseline identifies each existing assertion and rejects increased occurrence counts.
+
+**Search text for `as unknown`.** Rejected because comments and strings can contain the phrase, while whitespace, comments between tokens, and angle-bracket syntax can conceal an actual assertion from a text pattern.
+
+## Consequences
+
+Owners must resolve a cast before changing or moving its assertion into an unlisted form or file. Any change to an operand token, including an identifier rename or a quote, trailing comma, or parentheses edit, invalidates the fingerprint even when runtime behavior is unchanged. Formatting whitespace and comments alone do not change the recorded tokens. Removing a cast also requires pruning its baseline entry, so obsolete allowances cannot silently accumulate.
+
+Passing this check establishes absence of unlisted direct assertions to `unknown`, including union targets absorbed by a direct `unknown` member. It does not establish general type safety or resolve type aliases or intersections. JavaScript JSDoc `@type` assertions, `unknown[]`, `Promise`, and other containing types are outside this syntactic rule. Replacing a cast with `any`, another unchecked assertion, or a helper that merely hides the conversion does not resolve the underlying typing problem.
diff --git a/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.zh.md b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.zh.md
new file mode 100644
index 0000000000..3b66236e58
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-19-no-unknown-casts.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 禁止新增到 unknown 的类型断言
+
+Status: implemented
+
+[English](2026-09-19-no-unknown-casts.md) | 中文
+
+## 问题
+
+经由 `unknown` 的类型断言会移除 TypeScript 对原始值与后续断言类型之间的兼容性检查。生产代码可能因此掩盖错误的接口,测试也可能声称不完整的 fixture(测试前置数据)满足某项服务类型。现有用法还包括合理地将无类型的解析结果拓宽为 `unknown`,这种情况可以改用显式的 `unknown` 声明。
+
+仓库中的断言分布在许多相互独立的包和测试中。一次性全部移除会把执行规则的决策与无关的类型、fixture 重新设计混在一起,使每处替换的行为更难审查。
+
+## 决策
+
+第一方 JavaScript 和 TypeScript 禁止新增直接断言到 `unknown` 的表达式,范围包括测试、fixture、基准测试和脚本。规则覆盖 `as unknown` 和 `` 两种语法,也覆盖带括号的目标类型,以及直接包含 `unknown` 成员的联合目标类型。`unknown` 声明仍然有效:外部数据可以通过 `const value: unknown = JSON.parse(text)` 进入,再交给其声明的解析器处理后使用。同进程内已有类型的值仍遵循不得添加不必要运行时校验的现有规则。
+
+[检查器](../../../../scripts/verify-no-unknown-casts.ts)通过 `pnpm run verify-no-unknown-casts` 运行,使用 TypeScript 语法树。它扫描已跟踪的源文件和未被忽略的未跟踪源文件;vendored 源码和已冻结的归档 Agent Notes 保留各自维护规则要求的排除项。检查器忽略注释、字符串和 `unknown` 声明。
+
+现有断言使用精确基线记录,键由仓库相对路径、断言语法 token 的 SHA-256 摘要及出现次数组成。基线修改只能删除条目或减少次数;审查者必须拒绝新增文件、新增指纹和增加次数。基线之外新增或变化的断言以及陈旧的基线条目都会使检查失败;整个文件的放行或数量预算不能授权替换成新的技术债。`--prune` 机械地删除条目或减少次数,存在新增违规时拒绝写入。没有可添加新例外的命令。
+
+检查器加入 CI 静态模式和主模式,以及[门禁运行器](../../../../scripts/run-gates.ts)中的本地 `hygiene` 和 `check-all` 配置。现有断言仍是明确记录的技术债,由所属模块通过适当的类型、解析器或 fixture 修改来移除。
+
+## 考虑过的替代方案
+
+**先移除所有现有断言,再执行规则。** 不采用,因为清理涉及互不相关的服务、协议解析器和测试替身。精确记录现有例外后即可开始执行规则,无须把批量替换视为类型安全的改善。
+
+**放行整个文件或设置仓库级数量限制。** 不采用,因为两者都允许用新增断言替代已删除的断言。基线识别每个现有断言,并拒绝增加出现次数。
+
+**在文本中搜索 `as unknown`。** 不采用,因为注释和字符串可能包含这段文字,而空白、token 之间的注释和尖括号语法可能让实际断言躲过文本模式。
+
+## 影响
+
+所属模块必须先解决类型转换问题,才能将断言改为未登记的形式或移至未登记的文件。操作数 token 的任何变化,包括标识符重命名或引号、尾随逗号、括号修改,即使不改变运行时行为,也会使指纹失效。仅修改格式空白和注释不会改变所记录的 token。删除断言后还须缩减其基线条目,防止无效例外悄然累积。
+
+通过检查只说明没有未登记的、直接到 `unknown` 的类型断言,其中包括被直接 `unknown` 成员吸收的联合目标类型。它不证明整体类型安全,也不解析类型别名或交叉类型。JavaScript JSDoc `@type` 断言、`unknown[]`、`Promise` 等包含该类型的类型不属于这条语法规则。将断言替换成 `any`、另一种未经检查的断言,或仅隐藏转换的辅助函数,都没有解决底层类型问题。
diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
index 16094b8d00..a8f49b1780 100644
--- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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/simplification/2026-07-28-remove-synthetic-log-only-turns.md
-2026-07-28-remove-synthetic-log-only-turns.md: b35045d036dbb3ea18431b76cec2c41674de5607
-2026-07-28-remove-synthetic-log-only-turns.zh.md: 3dbcd0d9b258f4ea9f4515ba009e75487a96c342
+2026-07-28-remove-synthetic-log-only-turns.md: 073ac0888054c907087dd0c5f2d01b03a5e87455
+2026-07-28-remove-synthetic-log-only-turns.zh.md: 67b24c7423ee37c40361cb937fdad42243362dfd
diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md
index b35045d036..073ac08880 100644
--- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md
+++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md
@@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn
The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compaction/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission.
-`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) limits its completed-turn operation to the selected closing event.
+`SessionStore.fork()` preserves standalone title and other plugin-owned log-only records. The [exact-event fork decision](../feature/2026-08-18-arbitrary-seq-session-fork.md) owns cut selection and child-owned closers for an open tail.
The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics.
@@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2
## Verification
-Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. A JSONL round trip preserves a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol.
+Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. A JSONL round trip preserves a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol.
## Consequences
diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md
index 3dbcd0d9b2..67b24c7423 100644
--- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md
@@ -20,7 +20,7 @@ Status: implemented
标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compaction/* { turn: null }` 标记对,但会显式刷写已闭合的尝试,因为 `/compact` 承诺在放行排队中的提示词前完成持久化。
-`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)将其已结束轮次操作限制在选中的结束事件处。
+`SessionStore.fork()` 保留独立标题和其他插件所属的纯日志记录。[精确事件 fork 决策](../feature/2026-08-18-arbitrary-seq-session-fork.zh.md)负责切点选择与开放尾部的子会话自有结束事件。
历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。
@@ -36,7 +36,7 @@ Status: implemented
## 验证
-核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。
+核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。
## 后果
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
index b85c97a4b8..cd6b1146aa 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.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/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
-2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc
-2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0
+2026-09-03-minimal-profiles-persistent-shell-only.md: 2bc4ef7ada0767d96c0ac448e19e13e8e06a945c
+2026-09-03-minimal-profiles-persistent-shell-only.zh.md: d54e5734ac04825e2d28f59d9104a01b5caf9fb7
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
index d58210cb89..2bc4ef7ada 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
@@ -16,7 +16,7 @@ The shipped minimal compositions expose exactly one platform-selected persistent
The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example.
-The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged.
+The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior. Persistent Bash and PowerShell settle cancelled commands only after shell cleanup; the existing ToolRuntime then publishes `ABORTED` instead of stringifying the user's cancellation object. Queued cancelled calls send no command, and execution or cleanup failures retain their errors.
Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing.
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
index 481ef479c2..d54e5734ac 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
@@ -16,7 +16,7 @@ Status: implemented
独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。
-共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。
+共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为。持久 Bash 和 PowerShell 在 shell 清理完成后才结束已取消命令,由现有 ToolRuntime 发布 `ABORTED`,不把用户取消对象转成字符串。排队期间被取消的调用不发送命令,执行或清理失败仍保留各自的错误。
精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。
diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml
index 264c685eb9..e5ab7e62d9 100644
--- a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.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/simplification/2026-09-07-file-content-scan.md
-2026-09-07-file-content-scan.md: bf4f03ee5a7134b11c7cbec322b0a37a3f2a6304
-2026-09-07-file-content-scan.zh.md: 5efb1589b036f80b46752a80103c080952c82396
+2026-09-07-file-content-scan.md: 1ec46e7d685394f5165624f1e7b68622ba9f89d3
+2026-09-07-file-content-scan.zh.md: b4ebd835ca8adaf3a7b7fa09150cd5a2bec9f401
diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md
index bf4f03ee5a..1ec46e7d68 100644
--- a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md
+++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md
@@ -6,11 +6,11 @@ English | [中文](2026-09-07-file-content-scan.zh.md)
## Problem
-Every model dispatch checks complete message content for files, including nested tool results. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze evidence](2026-09-06-agent-request-freeze-evidence.md) removes repeated request freezing. The hot LLM source is identical at both sampled master revisions and the measured V3 integration revision; these observations do not establish PR causality.
+Every model dispatch checks complete message content for files, including tool-role result content. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze evidence](2026-09-06-agent-request-freeze-evidence.md) removes repeated request freezing. The hot LLM source is identical at both sampled master revisions and the measured V3 integration revision; these observations do not establish PR causality.
## Decision
-[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, nested tool-result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, and request construction keep their existing behavior. The [request-freeze evidence](2026-09-06-agent-request-freeze-evidence.md) owns the request-history budget.
+[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, tool-role result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, and request construction keep their existing behavior. The [request-freeze evidence](2026-09-06-agent-request-freeze-evidence.md) owns the request-history budget.
## Measurement evidence
diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md
index 5efb1589b0..b4ebd835ca 100644
--- a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## Problem
-每次模型分发都检查完整消息内容中的文件,包括嵌套工具结果。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)消除了重复请求冻结,这次遍历仍然必需。两次抽样的 master 修订与实测的 V3 集成修订 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。
+每次模型分发都检查完整消息内容中的文件,包括 tool-role 结果内容。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)消除了重复请求冻结,这次遍历仍然必需。两次抽样的 master 修订与实测的 V3 集成修订 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。
## Decision
-[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、嵌套工具结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影和请求构建保持既有行为。[请求冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)拥有请求历史预算。
+[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、tool-role 结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影和请求构建保持既有行为。[请求冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)拥有请求历史预算。
## Measurement evidence
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.i18n.yaml
new file mode 100644
index 0000000000..d589324465
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.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-08-web-subagent-catalog-projections.md
+2026-09-08-web-subagent-catalog-projections.md: 766d33e581bcac53844b0836f23cda615858fc27
+2026-09-08-web-subagent-catalog-projections.zh.md: ef14db2530ed10a7aa34408c5a6fada6f0a28858
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md
new file mode 100644
index 0000000000..766d33e581
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md
@@ -0,0 +1,31 @@
+# Agent Note: Web subagent catalogs consume shared projections
+
+Status: implemented
+
+English | [中文](2026-09-08-web-subagent-catalog-projections.zh.md)
+
+## Problem
+
+The parent catalog projection already publishes complete membership through the Session control stream. A separate catalog-change event, repeated RPC reads, and a second membership cache duplicate that delivery and require coordination between responses and later events. Historical Sessions still need initial loading when their projection is absent, and parent Agent availability remains independent of durable membership.
+
+## Decision
+
+The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened catalog branches still use explicit reads.
+
+Feature consumers select `subagentCatalog` from `projectionsBySession` and derive row activity from Session-list baselines and status events. Session summaries report Agent availability separately; Agent creation and disposal republish the existing summary event. Completed subagents retain their summaries and display projections after removal. Generic projection reads own loading and error state; parent availability comes from the Session list independently of membership. Opening a loaded catalog reuses its data; an initial failure can be retried. Reconnect cancels previous reads and starts fresh reads for requested catalogs. Session removal cancels its pending read and marks parent availability false; projection responses cannot change Agent availability.
+
+The [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns durable creation facts and ordering. This decision supersedes the dedicated membership-refresh mechanism in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md); that note remains active for navigation, control, and presentation decisions.
+
+Host summaries replace running and availability state; local create/fork placeholders only enrich missing metadata. Sharing overwrite behavior would let a late local response erase a newer Host state and generate a false completion reminder. Removed ordinary Sessions retain stores only for nonempty child catalogs; empty catalogs carry no navigation that needs to survive removal. Breadcrumb selectors derive addresses from the Session-list snapshot so their subscriptions cover every dependency.
+
+## Alternatives considered
+
+**Keep notification-driven reads.** Rejected because the shared stream already delivers the changed value. Menu subscriptions, response-local activity replay, and trailing membership reads add no required delivery capability.
+
+**Remove the initial-read endpoint.** Deferred. Session-list projections are cache hints and may be absent; following a conversation also transfers history and retains observation state. A lightweight read preserves historical catalog access without establishing a conversation follow stream. Parent availability is a live delivery hint, not a durable projection fact.
+
+## Consequences
+
+Membership changes use the existing control stream without extra catalog notifications or reads. Whole-value projection publication still costs O(D) for D children; this change does not introduce delta transport. Initial reads return the observation's complete projection baseline, including values beyond the catalog, so they reuse standard baseline semantics at the cost of that additional initial payload. Session sequence ordering does not establish identity across reused Session ids.
+
+Manager tests cover pushed membership, stale initial responses, lazy loading, status composition, retained completion metadata, retry, removal of empty catalogs, late create/fork responses, and reconnect cancellation. Host tests exercise the shared control stream and cold observations. The persisted-subagent Web scenario covers nested historical navigation and continuation through the shipped application.
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md
new file mode 100644
index 0000000000..ef14db2530
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md
@@ -0,0 +1,31 @@
+# Agent Note:Web 子代理目录消费共享 projection
+
+Status: implemented
+
+[English](2026-09-08-web-subagent-catalog-projections.md) | 中文
+
+## 问题
+
+父目录 projection 已通过 Session control stream 发布完整成员关系。独立的目录变化事件、重复 RPC 读取与第二份成员缓存重复传递同一数据,并要求协调响应与后续事件。历史 Session 的 projection 缺席时仍需初始加载,父 Agent 可用性也独立于持久成员关系。
+
+## 决策
+
+Client 仅在标准逐 Session projection store 中保留目录成员关系。初始 `session.projections` 读取通过一次 live-preferred Session observation 返回完整 projection 基线。endpoint 接受任意 Session id,不含目录专用依赖或校验。值与序号游标来自同一次 observation。初始基线与实时 control 帧使用 store 既有的序号排序,因此旧响应不能替换新值。该 endpoint 不激活 Agent,也不采样子代理活动状态。打开会话时使用其 follow baseline,不再额外调度一次投影读取;未打开的目录分支仍使用显式读取。
+
+功能消费者从 `projectionsBySession` 中选择 `subagentCatalog`,并从 Session 列表基线与状态事件派生行活动状态。Session 摘要单独报告 Agent 可用性;Agent 创建与销毁重新发布已有摘要事件。完成的子代理在移除后保留摘要与展示 projection。通用 projection 读取拥有加载与错误状态;父 Agent 可用性来自 Session 列表,独立于成员关系。打开已加载目录会复用数据,初始失败可以重试。重连取消此前的读取,并为已请求的目录重新读取。Session 移除会取消其待完成读取并将父 Agent 可用性标为 false;projection 响应不能改变 Agent 可用性。
+
+[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明持久创建事实与顺序。本决策取代 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 中的专用成员刷新机制;该记录继续保留导航、控制与展示决策。
+
+Host 摘要替换运行状态与可用性;本地 create/fork 占位摘要只补充缺失的元数据。若共用覆盖行为,晚到的本地响应会抹去较新的 Host 状态,并生成虚假的完成提醒。普通 Session 移除后,仅为非空子目录保留 store;空目录没有需要保留的导航关系。面包屑 selector 从 Session 列表快照推导地址,使订阅涵盖全部依赖。
+
+## 考虑过的替代方案
+
+**保留通知驱动的读取。** 不采用,因为共享流已经传递变化后的值。菜单订阅、请求内活动状态回放与尾随成员读取没有提供额外的必要传递能力。
+
+**删除初始读取 endpoint。** 暂缓。Session 列表 projection 是可能缺席的缓存提示;跟随会话还会传输历史并保留 observation 状态。轻量读取保留历史目录访问能力,无需建立会话跟随流。父 Agent 可用性是实时投递提示,而非持久 projection 事实。
+
+## 影响
+
+成员变化使用既有 control stream,无需额外目录通知或读取。对 D 个子级发布完整 projection 值仍需 O(D);本变更不引入增量传输。初始读取返回 observation 的完整 projection 基线,其中包括目录以外的值,以额外初始载荷换取标准基线语义的复用。Session 序号排序不确立复用 Session id 之间的身份关系。
+
+Manager 测试覆盖成员推送、过时初始响应、按需加载、状态组合、完成元数据保留、重试、空目录移除、晚到的 create/fork 响应与重连取消。Host 测试覆盖共享 control stream 与冷 observation。持久化子代理 Web 场景通过发布应用验证嵌套历史导航与续传。
diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml
index 1fe1deceac..6895faecfb 100644
--- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.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/simplification/2026-09-10-derived-workspace-recency.md
-2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
-2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c
+2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103
+2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56
diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
index e8f304110c..b967e332f2 100644
--- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
+++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
-Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
+Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md
index 60f2da36c2..87c2bb9f26 100644
--- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md
@@ -12,7 +12,7 @@ Status: implemented
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
-只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
+手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml
similarity index 52%
rename from .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml
rename to .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml
index d7871098ba..165022b7dc 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml
@@ -1,6 +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-03-resume-headers-do-not-repeat-system-prompts.md
-2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d
-2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md
+2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c
+2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf
diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md
new file mode 100644
index 0000000000..e1f7f95cfa
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md
@@ -0,0 +1,29 @@
+# Agent Note: Model agent availability and Team targets
+
+Status: implemented
+
+English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md)
+
+## Problem
+
+Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work.
+
+## Decision
+
+The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity.
+
+Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies.
+
+The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes.
+
+## Alternatives considered
+
+**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions.
+
+**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability.
+
+## Consequences
+
+Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision.
+
+Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority.
diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md
new file mode 100644
index 0000000000..0447d7d405
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 模型可见的 Agent 可用状态与 Team 目标
+
+Status: implemented
+
+[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文
+
+## 问题
+
+Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。
+
+## 决策
+
+Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。
+
+普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。
+
+Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。
+
+## 考虑过的替代方案
+
+**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。
+
+**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。
+
+## 影响
+
+模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。
+
+定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。
diff --git a/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.i18n.yaml
new file mode 100644
index 0000000000..a6c46b0bbd
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.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-16-subagent-catalog-membership-only.md
+2026-09-16-subagent-catalog-membership-only.md: d1d0956380ef2b2ffd610e5a3d07c7988991e42e
+2026-09-16-subagent-catalog-membership-only.zh.md: 4225a331a0b3c483349760cd805416a94c4b915a
diff --git a/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.md b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.md
new file mode 100644
index 0000000000..d1d0956380
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.md
@@ -0,0 +1,25 @@
+# Agent Note: Keep model configuration outside subagent catalogs
+
+Status: implemented
+
+English | [中文](2026-09-16-subagent-catalog-membership-only.zh.md)
+
+## Problem
+
+The parent catalog serves direct-child discovery. Recording a child's creation-time LLM provider and model would preserve immutable configuration without opening child histories, but discovery and navigation consumers do not read those values. Creation configuration also does not establish which model served a request.
+
+## Decision
+
+The catalog event and projection retain child identity, creation time, mode, and label. `establishCatalogChild(parent, childHeader, descriptor)` receives only the values needed to publish membership. Child composition and request history retain their own model configuration; catalog loading adds no child observation or model lookup.
+
+This decision supersedes only the creation-model metadata choice in the [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md). That note remains active for successful publication, event order, fork isolation, and projection ownership. The unreleased optional metadata declaration and its dedicated expectations are absent; existing historical Session generations from the migration base remain intact.
+
+## Alternatives considered
+
+**Keep optional creation metadata for future display.** Optional fields still require durable schemas, cache invalidation, producers, and replay expectations. A consumer requiring the immutable creation route could justify those costs; current consumers do not require it.
+
+**Read child histories during discovery.** This adds work proportional to child histories without helping membership. A display that needs the model used by a request can observe the child's existing `modelSelection` projection independently.
+
+## Consequences
+
+The catalog provides no historical creation-route record. Reintroducing one requires a consumer whose requirement cannot be met by the existing child composition or request projections. Initial reads, live updates, reconnect, and authorization keep their existing owners. Catalog and creation tests, recorded-session replay, and both SDK expectations verify that membership still publishes without the extra metadata.
diff --git a/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.zh.md b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.zh.md
new file mode 100644
index 0000000000..4225a331a0
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-16-subagent-catalog-membership-only.zh.md
@@ -0,0 +1,25 @@
+# Agent Note: 将模型配置保留在子代理目录之外
+
+Status: implemented
+
+[English](2026-09-16-subagent-catalog-membership-only.md) | 中文
+
+## 问题
+
+父目录用于发现直接子级。记录子级创建时的 LLM provider 和 model,可以在不打开子级历史的情况下保留不可变配置,但发现与导航的消费者不读取这些值。创建配置也不能确定实际处理某次请求的模型。
+
+## 决策
+
+目录事件与投影保留子级身份、创建时间、模式和标签。`establishCatalogChild(parent, childHeader, descriptor)` 只接收发布成员关系所需的值。子级组合与请求历史各自保留模型配置;目录加载不增加子级观察或模型查询。
+
+本决策仅替代[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)中的创建模型元数据选择。该记录继续说明成功发布、事件顺序、fork 隔离和投影所有权。未发布的可选元数据声明及其专属预期输出不予保留;迁移基线中已有的历史 Session 世代保持完整。
+
+## 考虑过的替代方案
+
+**保留可选创建元数据,供未来展示使用。** 可选字段仍需要持久化 schema、缓存失效、写入方和回放预期输出。需要不可变创建路由的消费者可以证明这些成本合理,但当前消费者没有此需求。
+
+**在发现过程中读取子级历史。** 这会增加与子级历史规模相关的工作,却不帮助确定成员关系。需要展示请求实际使用模型的功能,可以独立观察子级已有的 `modelSelection` 投影。
+
+## 后果
+
+目录不提供历史创建路由记录。重新引入此记录,需要存在无法通过已有子级组合或请求投影满足需求的消费者。初始读取、实时更新、重连和授权继续由现有模块负责。目录与创建测试、已记录 Session 回放以及两套 SDK 的预期输出验证了成员关系仍可在没有额外元数据的情况下发布。
diff --git a/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.i18n.yaml
new file mode 100644
index 0000000000..7dd1a29921
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.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-19-remove-desktop-profile-core-cleanup.md
+2026-09-19-remove-desktop-profile-core-cleanup.md: a42100e8dc247611e29ddd2b8075b3b98e388177
+2026-09-19-remove-desktop-profile-core-cleanup.zh.md: 9a6be335a427e3f3083362ffd9e3aaa2f236635d
diff --git a/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md
new file mode 100644
index 0000000000..a42100e8dc
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md
@@ -0,0 +1,40 @@
+# Agent Note: Remove the Desktop production profile core-package cleanup
+
+Status: implemented
+
+English | [中文](2026-09-19-remove-desktop-profile-core-cleanup.zh.md)
+
+## Problem
+
+Since 2026-09-15, production Desktop cleaned the profile before starting the Host, using the package names listed by the runtime descriptor: it deleted same-named entries under `$DSH_HOME/profiles/desktop/node_modules`, pruned the manifest's dependency declarations and pnpm overrides for those names, discarded the lockfile when package state changed, and removed development-time links once according to `desktop-runtime-state.json`. It targeted two kinds of residue: earlier Desktop builds had installed the core packages into the profile as local tarballs through pnpm, writing declarations, overrides, and a lockfile; and the development mode of the link backend had projected the installation closure into the same profile's `node_modules`. Under nearest-wins resolution those copies shadowed the runtime bundled with the application, combining an old Web frontend with new plugins.
+
+Both kinds of residue exist only on internal development and test machines; Desktop has not been released. Since the runtime ships inside the application, new profiles no longer contain core packages, and after the [link backend removal](../architecture/2026-09-19-profile-resolution-lookup-order.md) development mode writes no links into the profile. The cleanup had to rerun on every production launch and fought the next pnpm operation, which reinstalled packages from the retained declarations.
+
+## Decision
+
+Delete `apps/desktop/src/profile-core-cleanup.ts`, `apps/desktop/src/profile-packages.ts`, and their two specs. `DesktopProjectManager.applyRelease` validates the runtime descriptor, migrates profile settings, creates the profile files, and removes the projections a link-backend launch wrote through the shared `removeLinkProjections`; beyond that, neither production nor development launches modify the profile's `node_modules`, manifest, overrides, lockfile, or `desktop-runtime-state.json`.
+
+Resolution inside the profile follows the [lookup-order Note](../architecture/2026-09-19-profile-resolution-lookup-order.md): packages in the profile's own `node_modules` win as the nearest layer, and installation package names are occupied by the generation at `$DSH_HOME/profiles/node_modules`. When a package installed into the profile declares `@deepseek-ai/*` packages under `dependencies`, pnpm installs copies into the profile and those copies run at their own versions. Official packages keep only pure-function packages under `dependencies` and declare every package with module-level identity as a peer; a third-party plugin that declares an identity-bearing dsh package as a real dependency makes that packaging choice for itself.
+
+Capability given up: core-package copies and their declarations that earlier Desktop builds installed into a profile need one manual removal; `desktop-runtime-state.json` is no longer read or deleted. Projections the link backend wrote are removed by the shared profile load, see the [lookup-order Note](../architecture/2026-09-19-profile-resolution-lookup-order.md).
+
+Reintroduction conditions: a released Desktop wrote core-package copies into external users' profiles, or official packages change their dependency conventions so that profiles gain copies competing with the runtime for identity.
+
+## Alternatives considered
+
+**Keep the original cleanup.** The residue it served is no longer produced, yet it deleted directories, pruned declarations, and discarded the lockfile on every production launch, fighting the following pnpm operations.
+
+**Delete only same-named directories under the profile's `node_modules`, leaving declarations and the lockfile alone.** The 2026-09-15 decision already rejected this: pnpm reinstalls the same old packages from the retained declarations and overrides.
+
+**Give `@deepseek-ai/*` generation entries absolute precedence over same-named copies inside the profile.** That overrides versions plugins bring along and is a new resolution-rule decision outside the scope of removing the cleanup.
+
+**Clean only in the installer.** The 2026-09-15 decision already rejected this: it misses other profiles and cannot reach copies recreated after installation.
+
+## Verification
+
+- [project-manager.spec.ts](../../../../apps/desktop/tests/project-manager.spec.ts) asserts that after one launch preparation the installed core package directory, the third-party plugin directory, the manifest declarations, `desktop-runtime-state.json`, and the lockfile are byte-for-byte unchanged.
+- The repository has no remaining references to `cleanProfileCorePackages`, `migrateDesktopProfileLinks`, `CLEAN_PROFILE_CORE_PACKAGES`, or `DESKTOP_PROFILE_STATE`.
+
+## Consequences
+
+Bought: production launches no longer write into the profile, there is no temporary enable switch, and Desktop and the CLI apply one rule to copies inside a profile. Paid: core-package copies earlier Desktop builds installed into a profile are removed by hand; dsh copies that third-party plugins bring into the profile as real dependencies run at their own versions.
diff --git a/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.zh.md b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.zh.md
new file mode 100644
index 0000000000..9a6be335a4
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.zh.md
@@ -0,0 +1,40 @@
+# Agent Note: 移除 Desktop 生产启动前的核心包清理
+
+Status: implemented
+
+[English](2026-09-19-remove-desktop-profile-core-cleanup.md) | 中文
+
+## Problem
+
+2026-09-15 起,生产版 Desktop 在启动 Host 前按运行时描述符列出的包名清理 profile:删除 `$DSH_HOME/profiles/desktop/node_modules` 里的同名条目,剪掉 manifest 对这些名字的依赖声明与 pnpm overrides,包状态变化时丢弃锁文件,并按 `desktop-runtime-state.json` 记录一次性移除开发期链接。它针对两类残留:更早的 Desktop 曾把核心包做成本地 tarball 用 pnpm 装进 profile 并写入声明、overrides 与锁文件;Link 后端的开发模式曾把安装闭包投影进同一个 profile 的 `node_modules`。在最近者优先的解析下,这些副本会压过应用自带的运行时,把旧 Web 前端与新插件组合在一起。
+
+这两类残留只存在于内部开发与测试机器上,Desktop 没有对外发布过。运行时随应用打包后,新 profile 不再包含核心包;[Link 后端删除](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md)后,开发模式不再向 profile 写任何链接。清理每次生产启动都要重跑,并与下一次 pnpm 操作按保留的依赖重新安装形成拉锯。
+
+## Decision
+
+删除 `apps/desktop/src/profile-core-cleanup.ts`、`apps/desktop/src/profile-packages.ts` 及其两个 spec。`DesktopProjectManager.applyRelease` 校验运行时描述符、迁移 profile 设置、创建 profile 文件,并通过共享的 `removeLinkProjections` 删除 Link 后端启动写下的投影;除此之外,生产与开发启动都不改动 profile 的 `node_modules`、manifest、overrides、锁文件与 `desktop-runtime-state.json`。
+
+profile 内的解析按[查找顺序 Note](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md):profile 自己 `node_modules` 里的包最近者优先,本体包名在 `$DSH_HOME/profiles/node_modules` 的位置由 generation 占据。装进 profile 的包若在 `dependencies` 里声明了 `@deepseek-ai/*`,pnpm 会把副本装进 profile,这些副本按其自身版本运行。官方包只把纯函数包放在 `dependencies`,带模块级身份的包一律声明为 peer;第三方插件把身份敏感的 dsh 包写成实依赖,是该插件自身的打包选择。
+
+放弃的能力:旧版 Desktop 装进 profile 的核心包副本及其声明需要手工删除一次;`desktop-runtime-state.json` 不再被读取或删除。Link 后端写下的投影由共享的 profile 加载一次性删除,见[查找顺序 Note](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md)。
+
+重新引入的条件:外部用户的 profile 曾被某个发布版 Desktop 写入核心包副本,或官方包改变依赖声明约定,使 profile 内出现与运行时争抢身份的副本。
+
+## Alternatives considered
+
+**保留原清理。** 它服务的残留不再产生,却在每次生产启动删除目录、剪声明并丢弃锁文件,与随后的 pnpm 操作反复拉锯。
+
+**只删除 profile `node_modules` 里的同名目录,不动声明与锁文件。** 2026-09-15 的决策已经否决:pnpm 会按保留的声明与 overrides 重装相同的旧包。
+
+**让 generation 中的 `@deepseek-ai/*` 条目绝对优先于 profile 内的同名副本。** 这会覆盖插件自带的版本,是解析规则的新决策,不在移除清理的范围内。
+
+**只在安装器中清理。** 2026-09-15 的决策已经否决:遗漏其他 profile,也管不到安装后重新产生的副本。
+
+## Verification
+
+- [project-manager.spec.ts](../../../../apps/desktop/tests/project-manager.spec.ts) 断言一次启动准备后,profile 里已安装的核心包目录、第三方插件目录、manifest 声明、`desktop-runtime-state.json` 与锁文件逐字节不变。
+- 仓库中不再有 `cleanProfileCorePackages`、`migrateDesktopProfileLinks`、`CLEAN_PROFILE_CORE_PACKAGES` 与 `DESKTOP_PROFILE_STATE` 的引用。
+
+## Consequences
+
+买到的:生产启动不再写 profile,没有临时启用开关,Desktop 与 CLI 对 profile 内副本采用同一套规则。付出的:旧版 Desktop 装进 profile 的核心包副本靠手工清理;第三方插件以实依赖带进 profile 的 dsh 副本按其自身版本运行。
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
index f25fc6d416..2eb9f0f8ea 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.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-04-session-open-performance-gate.md
-2026-09-04-session-open-performance-gate.md: eaf7c6045bca12329d81cf9c968fb191229b0add
-2026-09-04-session-open-performance-gate.zh.md: e9bf13460aafbd962a1d15b7a7d240f0b4be6b4e
+2026-09-04-session-open-performance-gate.md: 1b2b0aabb79aceb0ac515a1d90ba3e2ba042535c
+2026-09-04-session-open-performance-gate.zh.md: 9ad56796aac6310977aedfff0f9188e46cbbf3d7
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
index eaf7c6045b..1b2b0aabb7 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
@@ -78,6 +78,22 @@ The calibrated source budgets are:
| Client-fold delta scaling | 2.5× | 3.125× |
| Constrained old space | — | 128 MB |
+### Indexed traversal of stored JSON arrays
+
+The JSONL backend freezes decoded event graphs before memoization. Its native decoder and fixed migration catalog produce acyclic JSON graphs whose arrays contain only indexed values. Numeric array traversal preserves freezing of every nested object and array while avoiding string-key enumeration across the compact stream's 125,000 text entries and 124,600 time deltas. Object traversal still preserves own `__proto__` and `constructor` data. No admission, relationship validation, decoding, or freezing moves beyond the measured open endpoint, and a shallow `Object.isFrozen()` result never skips descendant traversal.
+
+On an Apple M4 Pro with Node 24.19.0, an open-only CPU profile attributes about 4.2 ms to complete event freezing. Separate fresh-process V4 runs give these results; the final column restores and rebuilds the exact original traversal as a negative control:
+
+| Reopen measurement | Original traversal | Indexed traversal | Original restored |
+|---|---|---|---|
+| `open` samples (ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 |
+| Median `open` (ms) | 22.6 | 17.7 | 21.7 |
+| Median first history (ms) | 35.0 | 30.6 | 33.6 |
+| Median Agent resume (ms) | 30.5 | 26.8 | 29.8 |
+| Agent retained heap (MB) | 4.5 | 4.5 | 4.5 |
+
+Each run passes all 17 Session cases, including the 128 MB completion checks. The original implementation remains below 63 ms on this local machine; the restored traversal demonstrates the removed cost, while the hosted benchmark remains the required budget verdict. The workload, time limits, memory limits, cold-process setup, and measurement endpoints are unchanged. The JSONL owner tests cover nested opaque JSON, rejected mutation, and immutable event sharing through historical V3 and current reads. These measurements establish a reopen improvement; they do not establish a consistent first-open improvement or hosted-runner timing.
+
## Alternatives considered
**Check out the historical commit and compare it on every CI run.** Rejected because a historical checkout requires a separate install, and old and current revisions can assign work to different API phases, adding runtime, dependency, and interface drift. A fixed workload with static budgets calibrated against positive and negative controls is easier to reproduce and review.
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
index e9bf13460a..9ad56796aa 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
@@ -78,6 +78,22 @@ PR #3640 的标准双 CPU 运行 (run 34023970384, job 101461539961)使用 Node
| Client fold delta 缩放比 | 2.5× | 3.125× |
| 受限 old space | — | 128 MB |
+### 按索引遍历持久化 JSON 数组
+
+JSONL backend 在 memo 化前冻结解码后的 event graph。原生 decoder 与固定 migration catalog 产出无环 JSON graph,其中数组只包含索引值。按数字索引遍历数组仍会冻结每个嵌套对象和数组,同时避免枚举 compact stream 中 125,000 个文本条目和 124,600 个时间差值的字符串键。对象遍历继续保留自有的 `__proto__` 和 `constructor` 数据。Admission、关系校验、解码与冻结都仍在被测 open 终点前完成;浅层 `Object.isFrozen()` 结果不会跳过后代遍历。
+
+在 Apple M4 Pro、Node 24.19.0 上,仅覆盖 open 的 CPU profile 将约 4.2 ms 归因于完整 event 冻结。独立的全新进程 V4 运行得到以下结果;末列恢复并重新构建完全相同的原始遍历,作为负对照:
+
+| Reopen 测量项 | 原始遍历 | 索引遍历 | 恢复原始遍历 |
+|---|---|---|---|
+| `open` 样本(ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 |
+| `open` 中位数(ms) | 22.6 | 17.7 | 21.7 |
+| 首屏历史中位数(ms) | 35.0 | 30.6 | 33.6 |
+| Agent resume 中位数(ms) | 30.5 | 26.8 | 29.8 |
+| Agent 保留堆(MB) | 4.5 | 4.5 | 4.5 |
+
+每次运行的全部 17 个 Session 用例均通过,包括 128 MB 完成性检查。原始实现在本机仍低于 63 ms;恢复原始遍历展示了被移除的成本,而托管 benchmark 仍负责给出必须满足的预算结果。工作负载、时间上限、内存上限、冷进程准备与测量终点保持不变。JSONL owner 测试覆盖历史 V3 和当前读取中的嵌套不透明 JSON、修改拒绝及不可变 event 共享。这些测量确认了 reopen 改善,但没有确认一致的 first-open 改善或托管运行器耗时。
+
## 考虑过的替代方案
**每次 CI checkout 历史提交并做相对比较。** 拒绝:历史 checkout 需要独立安装,旧版与当前版还可能把工作放在不同 API 阶段,增加时间、依赖和接口漂移。固定 workload 与经正反例校准的静态预算更容易复现和评审。
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
index f63c4924fd..aeaed20365 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
-2026-09-06-backend-continuation-performance.md: a30c7a47a93f0a8827d1cb4d0857aeabe9dd2fee
-2026-09-06-backend-continuation-performance.zh.md: c703ea70e496d279e5965295d308a2919ed2b445
+2026-09-06-backend-continuation-performance.md: a2423a404e23f6a4ccb6a4fb177ec6842b7bf584
+2026-09-06-backend-continuation-performance.zh.md: 942cfd37528d121bf482c48c5d75dd1f2de30887
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
index a30c7a47a9..a2423a404e 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
@@ -19,13 +19,13 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a
| Request history | After unmeasured cold resume, deliver 40 sequential text-only turns over the tool-heavy history, then flush | Idle Agent with all 40 model requests completed; reports turn and final-flush time separately |
| Tool continuation | Cold resume, 20 sequential turns with eight parallel-safe synthetic tool calls and a final reply per turn, then flush | Idle Agent with 40 model requests and 160 completed tool executions; reports resume, turns, and final flush separately |
| Shipped SDK workflow | Launch built dsh with the sdk-minimal profile, deliver 100 sequential turns with eight real file-view calls per turn, then close the SDK | SDK receives 200 assistant messages and 800 successful file results; includes Loader boot, stdio JSON-RPC, persistence, and shutdown |
-| Child catalog | List 16 inactive seeded fork children twice through the real subagent and Session query services | Two complete healthy catalogs with observations released; each child inherits 80 tool-heavy turns and owns its descriptor after the exact fork cut |
+| Child catalog | Observe one cold parent catalog twice through the real Session query and subagent services | Two complete 16-child catalogs with parent observations released; each stored fork child inherits 80 tool-heavy turns, and discovery reads only the parent facts |
The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input.
The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same.
-Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
+Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released parent observations.
The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
@@ -41,7 +41,7 @@ The implementation reference is the PR #3537 merge on Apple M4 Pro, macOS arm64,
Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../simplification/2026-09-06-agent-request-freeze-evidence.md), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
-A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings remain separate because a second listing still reads body-bearing seeded children after observations are released.
+A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings separately report the first and second parent-catalog observations.
The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
@@ -59,7 +59,7 @@ Baseline request history uses a 600 ms expected CI duration with 1.25× headroom
**Measure only deriveMessages.** Rejected: its incremental cache does not include complete request freezing, adapter dispatch, live append, or persistence. Actual sequential requests protect the cost the Agent pays per step.
-**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The catalog intentionally omits the optional projection cache and reports the seeded fallback path; it does not characterize cache-hit discovery.
+**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The original calibration omits the optional projection cache and reports the seeded fallback path. Current catalog reads observe durable parent facts; the recorded child-body timings describe the calibration implementation.
**Apply an optimization and its desired budget together with the first measurements.** Rejected: a baseline-only layer remains independently mergeable and records the current workload before attribution or implementation changes. Source constants cannot be overridden by environment variables.
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
index c703ea70e4..942cfd3752 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
@@ -19,13 +19,13 @@ Status: implemented
| 请求历史 | 在不计时的冷恢复后,向工具密集历史顺序提交 40 个纯文本轮次,然后 flush | 空闲 Agent,已完成全部 40 次模型请求;分别报告轮次和最终 flush 时间 |
| 工具续聊 | 冷恢复,顺序执行 20 个轮次,每轮八次可安全并行的合成工具调用和一条最终回复,然后 flush | 空闲 Agent,已完成 40 次模型请求和 160 次工具执行;分别报告恢复、轮次和最终 flush 时间 |
| 已发布 SDK 工作流 | 使用 sdk-minimal profile 启动已构建 dsh,顺序提交 100 个轮次,每轮八次真实文件查看调用,然后关闭 SDK | SDK 收到 200 条助手消息和 800 个成功文件结果;包含 Loader 启动、stdio JSON-RPC、持久化和关闭 |
-| 子会话目录 | 通过真实 subagent 和 Session 查询服务,两次列出 16 个非活动、带种子的 fork 子会话 | 两份完整健康目录,观察已释放;每个子会话继承 80 个工具密集轮次,并在精确 fork 切点后拥有自己的描述符 |
+| 子会话目录 | 通过真实 Session 查询和 subagent 服务,两次观察一个冷 parent 目录 | 两份完整的 16 子级目录,parent 观察已释放;每个存储的 fork 子级继承 80 个工具密集轮次,发现只读取 parent fact |
工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。
SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。
-五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
+五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的父会话观察。
父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
@@ -41,7 +41,7 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求冻结校准](../simplification/2026-09-06-agent-request-freeze-evidence.zh.md)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
-独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别保留,因为观察释放后第二次列举仍读取带种子子会话的正文。
+独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别报告第一次和第二次 parent 目录观察。
已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
@@ -59,7 +59,7 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
**只测 deriveMessages。** 拒绝:它的增量缓存不包含完整请求冻结、适配器分发、实时追加或持久化。实际顺序请求保护 Agent 每一步支付的成本。
-**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。目录用例有意不挂载可选投影缓存,报告带种子的回退路径;它不代表缓存命中的发现过程。
+**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。原始校准有意不挂载可选投影缓存,报告带种子的回退路径。parent 自有目录的测量观察持久 parent fact;子级正文读取只属于已记录的校准实现。
**将优化及其目标预算与首次测量一起应用。** 拒绝:纯基线层可以独立合并,并在归因或实现改变前记录当前负载。环境变量不能覆盖源码常量。
diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml
new file mode 100644
index 0000000000..55faf9d686
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
+2026-09-10-desktop-local-updater-qualification.md: 10c0a384106cc06aaae50d8782b58114ea86fb79
+2026-09-10-desktop-local-updater-qualification.zh.md: 9d554bc5001fc53f60ba9cd0d08b02e975f8261f
diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
new file mode 100644
index 0000000000..10c0a38410
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md
@@ -0,0 +1,51 @@
+# Agent Note: Qualify Desktop update downloads with an isolated local server
+
+Status: implemented
+
+English | [中文](2026-09-10-desktop-local-updater-qualification.zh.md)
+
+## Problem
+
+Desktop update interaction depends on feed parsing, network failures, download integrity, and platform preparation. Simulated updater events cannot establish that these operations work together. Requiring cloud cache configuration and hardware signing for each feedback cycle makes local product validation depend on release infrastructure.
+
+## Decision
+
+The [Windows local qualification command](../../../../apps/desktop/README.md) runs the built production coordinator inside Electron with the real `ElectronHttpExecutor` and `NsisUpdater`. An isolated application adapter supplies a test version and private update configuration. A loopback server supplies Nightly YAML, mandatory policy, and inert binary bytes. The installer, external browser, and clipboard calls are replaced with observations. Publisher verification is absent only from this private fixture configuration, never from production configuration.
+
+Each invocation atomically allocates a temporary directory and an operating-system-assigned loopback port. Request barriers establish concurrency without sleeps. Server close and child exit are awaited before temporary files are removed. Independent processes can run concurrently without sharing updater cache or user data. The command requires built Desktop modules and stays separate from source-plane unit tests.
+
+This testing decision supplements the [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); it does not supersede signing, publication integrity, installed-artifact qualification, or the pending [mandatory update proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md). Those records remain active.
+
+## Alternatives considered
+
+**Use only simulated updater events.** They cover state transitions but cannot establish actual YAML parsing, Electron transport, downloaded bytes, or checksum rejection. Local qualification uses real dependency implementations; native-dialog and renderer component tests remain separate.
+
+**Require signed cloud-hosted packages for every local iteration.** That couples ordinary interaction debugging to signing hardware and CDN configuration. Release qualification still requires those systems, but local download tests do not.
+
+**Relax production signing or execute a dummy installer.** Neither is needed to observe download readiness and installation authorization. The fixture never loads production update configuration and records installation without launching downloaded bytes.
+
+## Consequences
+
+The executable scenarios cover no update, downgrade rejection, HTTP 404/408 and invalid YAML, stalled-feed and stalled-download deadlines, same-address feed replacement, one in-flight check or download, explicit download authorization, SHA-512 mismatch, interrupted transfers, explicit retry, prepared-version retention, restart refusal, shutdown failure, and disposal during a pending check. Native-dialog regressions cover checking feedback, check failure, download refusal, and separate installation action; account-row tests own progress and persistent retry presentation.
+
+The ordinary polling regression uses the real coordinator with a fake clock, instance-local random samples, and deferred network completion. It verifies completion-based jitter, capped exponential backoff, successful reset, manual failure visibility while joining an automatic check, retained download failures and prepared packages, and no timer rearming after disposal. Main-entry tests connect the same deadline to focus, resume, explicit IPC, and application shutdown. Removing failure backoff makes the timing assertion fail. These deterministic tests establish request timing, not fleet capacity or startup-burst distribution.
+
+The actual [mandatory-update window](../feature/2026-09-11-desktop-mandatory-update-client.md), sandboxed preload, and renderer button handlers run against the policy server and updater. A stalled policy request reaches its real deadline without clearing the block. Owner-local DOM expectations pin Chinese text and actions. Screenshot failures are recorded separately; missing screenshots cannot establish visual acceptance.
+
+The [workspace browser scenario](../../../../apps/web/tests/desktop-updates.e2e.ts) checks built sidebar composition in both locales against the production presentation function. A page-local carrier supplies update state and records actions; the Host, client plugins, styles, and connection remain real. This separates slot placement and click-guard evidence from Electron IPC, task authorization, and installation evidence. Screenshots and result files use an invocation-specific ignored directory and do not replace release qualification.
+
+The [Host qualification runner](../../../../apps/desktop/scripts/test-host-updates.ts) loads the built Host and standard agent preset in a private profile. Real task registries, queued messages, pending tool questions/approvals, and Node jobs establish task-detection and admission-lock evidence without replacing Host composition. Scripted model output and held human answerers control the waiting points; cancellation and process exit establish completion. This evidence excludes the development launcher's dependency projection, Electron installation confirmation, and failed task shutdown. Concurrent invocations own separate home, project, and session directories.
+
+The [Electron workspace runner](../../../../apps/desktop/scripts/test-workspace-updates.ts) couples the compiled main entry and actual preload to a separate real Host. A private profile plugin controls real queued tasks and deliberately holds teardown; local HTTP delivery replaces release infrastructure, and the updater's installer call is intercepted. It verifies confirmation-time task changes, refused installation after failed teardown, replacement Host readiness, and fresh confirmation under ordinary and mandatory policy. Renderer actions wait for finite layout animations and reject obscured targets; window-listener cleanup retains web contents independently of destroyed windows. The runner does not prove bundled-main deployment or operating-system composition of overlapping windows.
+
+The runner launches Electron without hiding its GUI and asserts main-window visibility before input or screenshots. Hidden or occluded renderers can suspend frame callbacks even when DOM queries complete. Each DOM, click-layout, and screenshot operation temporarily disables background throttling on its own WebContents, then restores and checks the original setting. Real animations and click-obscuration assertions remain enabled; production window configuration is unchanged. A main-process deadline and invocation-local trace bound each operation; failure diagnostics record window visibility and animation state with their own deadline. Diagnostic capture failure cannot replace the original test failure. Re-enabling Windows process hiding fails the visibility assertion, and concurrent unprotected windows reproduce a suspended frame wait.
+
+The production `DesktopUpdateHttpExecutor` retains the library transport and adds an inactivity deadline to actual Electron requests. Electron 44 emits writable `close` before response headers, so that event cannot indicate HTTP completion. Response completion, abort, and errors release the timer; received bytes refresh it. Both stalled headers and stalled payloads fail and recover through an explicit retry in the local fixture.
+
+The [signed-download runner](../../../../apps/desktop/scripts/test-signed-updates.mjs) complements inert-byte tests with existing signed and unsigned executables, a public certificate, and real Windows Authenticode verification. Its private feed uses a synthetic version, and installation is intercepted. Correct-hash files with a wrong publisher or no signature are rejected and removed from cache; corrupt transfer bytes fail checksum verification first. Automatic checks retain the failure without another request, while explicit retry with a signed payload reaches readiness and requires separate restart approval. Inputs remain unchanged. Each process owns its port and cache; the deadline stops its complete Electron/PowerShell process tree before runtime cleanup. Missing publisher configuration makes the rejection assertion fail.
+
+Optional old-installer input enables real differential reconstruction from private cache and original blockmaps. The server supports single and multipart byte ranges, with explicit missing-blockmap and rejected-range failures. Hash and signature verification cover reconstructed and full-fallback results; request records require actual byte reuse and reject full fallback as differential success. Disabling differential download fails the blockmap-request assertion. Historical input files remain read-only, and independent concurrent runs retain separate caches and ports.
+
+The local download fixture injects `ENOSPC` into its executable write stream after writing partial bytes to disk. The actual updater must clear the partial executable, refuse installation, retain explicit retry, and download verified bytes after the original writer is restored. Only the private download path is intercepted, and stream closure is awaited. This qualifies handling of a filesystem write failure without exhausting a shared host volume.
+
+This evidence does not establish actual installer execution, post-restart health, production CDN Range behavior, macOS updater preparation, or actual volume exhaustion. The test servers publish nothing and never read cloud credentials; production delivery and signed installed-version upgrades remain separate qualification.
diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md
new file mode 100644
index 0000000000..9d554bc500
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md
@@ -0,0 +1,51 @@
+# Agent Note: 使用隔离的本地服务器验证 Desktop 更新下载
+
+Status: implemented
+
+[English](2026-09-10-desktop-local-updater-qualification.md) | 中文
+
+## 问题
+
+Desktop 更新交互依赖清单解析、网络失败、下载完整性和平台准备。模拟 updater 事件无法证明这些操作能够协同工作。每轮反馈都要求配置云端缓存并完成硬件签名,会让本地产品验证依赖发布基础设施。
+
+## 决策
+
+[Windows 本地验证命令](../../../../apps/desktop/README.zh.md) 在 Electron 中运行构建后的生产协调器,并使用真实的 `ElectronHttpExecutor` 和 `NsisUpdater`。隔离的应用适配器提供测试版本和私有更新配置。回环服务器提供 Nightly YAML、强更策略和不可执行的二进制字节。安装器、外部浏览器和剪贴板调用被替换为观测记录。发布者验证仅在此私有 fixture(测试前置数据)配置中缺省,生产配置不受影响。
+
+每次调用都原子分配临时目录和由操作系统分配的回环端口。请求屏障在不使用休眠的情况下建立并发条件。移除临时文件之前,测试会等待服务器关闭和子进程退出。独立进程可以并发运行,互不共享 updater 缓存或用户数据。此命令依赖构建后的 Desktop 模块,与源码侧单元测试分开运行。
+
+此测试决策补充[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md),并不取代签名、发布完整性、已安装产物验收或待实施的[强制更新提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)。这些记录保持有效。
+
+## 考虑过的替代方案
+
+**仅使用模拟 updater 事件。** 它们覆盖状态转换,但无法证明真实的 YAML 解析、Electron 传输、下载字节或校验和拒绝。本地验证使用真实依赖实现;原生弹窗和渲染器组件测试仍独立执行。
+
+**每轮本地迭代都要求云端托管的签名包。** 这会让常规交互调试依赖签名硬件和 CDN 配置。发布验收仍需要这些系统,但本地下载测试不需要。
+
+**放宽生产签名要求或执行伪安装器。** 观测下载就绪和安装授权不需要这两种做法。fixture 从不加载生产更新配置,只记录安装,不启动下载的字节。
+
+## 后果
+
+可执行场景覆盖无更新、拒绝降级、HTTP 404/408 和无效 YAML、清单与下载停滞截止时间、同地址清单替换、单一在途检查或下载、显式下载授权、SHA-512 不匹配、传输中断、显式重试、已准备版本保留、拒绝重启、关闭失败,以及检查未完成时的 dispose(资源释放)。原生弹窗回归覆盖检查反馈、检查失败、拒绝下载和独立安装操作;账户行测试负责进度和持久重试展示。
+
+常规轮询回归使用真实协调器、模拟时钟、实例私有随机样本和可控的网络完成时机。它验证从完成时刻起算的抖动、有上限的指数退避、成功重置、手动复用自动检查时的失败可见性、下载失败和已准备安装包保留,以及 dispose 后不重新启动计时器。主入口测试将同一期限连接到窗口聚焦、系统恢复、显式 IPC 和应用退出。移除失败退避会使计时断言失败。这些确定性测试证明请求时序,不证明客户端总体容量或启动峰值分布。
+
+真实的[强更窗口](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)、沙箱预加载和页面按钮处理会对接策略服务器与 updater。停滞的策略请求达到真实截止时间后不会清除阻塞。所属测试目录内的 DOM 预期输出约束中文文案和操作。截图失败单独记录;缺少截图不能证明视觉验收通过。
+
+[工作区浏览器场景](../../../../apps/web/tests/desktop-updates.e2e.ts) 使用生产展示函数检查两种语言下构建后的侧栏组合。页面私有的载体提供更新状态并记录操作;Host、客户端插件、样式和连接保持真实。slot 位置与点击保护的证据因此与 Electron IPC、任务授权和安装证据分开。截图和结果文件使用每次调用独有的忽略目录,不替代发布验收。
+
+[Host 验证运行器](../../../../apps/desktop/scripts/test-host-updates.ts) 在私有 profile 中加载构建后的 Host 和 standard agent 预设。真实任务注册表、排队消息、等待答复的工具提问/审批和 Node job 提供任务探测与准入锁定证据,不替换 Host 组合。脚本化模型输出和暂停的人工答复器控制等待点;取消和进程退出确立完成状态。这些证据不包含开发启动器的依赖投影、Electron 安装确认和任务停止失败。并发调用各自拥有独立的 home、项目和会话目录。
+
+[Electron 工作区运行器](../../../../apps/desktop/scripts/test-workspace-updates.ts) 将编译后的主入口和真实预加载连接到独立的真实 Host。私有 profile 插件控制真实排队任务并刻意阻塞停止过程;本地 HTTP 分发替代发布基础设施,updater 的安装器调用被拦截。它验证确认期间任务变化、收尾失败后拒绝安装、替代 Host 就绪,以及普通和强更策略下的重新确认。页面操作等待有限布局动画完成,并拒绝被遮挡的目标;窗口监听清理独立保留 web contents,不依赖已销毁窗口。运行器不证明打包主入口部署或重叠窗口的操作系统合成效果。
+
+运行器启动 Electron 时不隐藏 GUI,并在输入或截图之前断言主窗口可见。隐藏或被遮挡的渲染器即使能够完成 DOM 查询,也可能暂停帧回调。每次 DOM、点击布局和截图操作仅临时关闭自身 WebContents 的后台节流,随后恢复并检查原设置。真实动画和点击遮挡断言保持启用;生产窗口配置不变。每项操作都有主进程截止时间及本次运行独有的跟踪记录;失败诊断在独立截止时间内记录窗口可见性和动画状态。诊断捕获失败不能替换原始测试失败。重新启用 Windows 进程隐藏会使可见性断言失败,未保护的并发窗口可复现帧等待停滞。
+
+生产使用的 `DesktopUpdateHttpExecutor` 保留依赖库传输,并为真实 Electron 请求添加无活动截止时间。Electron 44 会在响应头到达前触发可写流的 `close`,因此该事件不能代表 HTTP 完成。响应完成、中止和错误释放计时器;收到字节会刷新计时器。响应头停滞与负载停滞均在本地 fixture 中失败,并通过显式重试恢复。
+
+[签名下载运行器](../../../../apps/desktop/scripts/test-signed-updates.mjs) 使用已有的签名及未签名可执行文件、公开证书和真实 Windows Authenticode 验签,补充不可执行字节测试。私有清单使用合成版本号,安装调用被拦截。哈希正确但发布者错误或未签名的文件被拒绝,并从缓存移除;传输损坏的字节先被校验和验证拒绝。自动检查保留失败,不发送新请求;显式重试签名文件后进入就绪,并要求单独批准重启。输入文件保持不变。每个进程独占端口和缓存;截止时间触发时先停止完整的 Electron/PowerShell 进程树,再清理运行时目录。缺少发布者配置会使拒绝断言失败。
+
+可选的旧安装器输入启用真实差分重建,使用私有缓存和原始 blockmap。服务器支持单段与多段字节范围,并显式提供缺少 blockmap 和拒绝 Range 的故障。哈希与签名验证覆盖重建及全量回退结果;请求记录要求实际复用字节,不允许全量回退冒充差分成功。禁用差分下载会使 blockmap 请求断言失败。历史输入文件保持只读,独立并发运行各自拥有缓存和端口。
+
+本地下载 fixture 在向磁盘写入部分字节后,为其可执行文件写入流注入 `ENOSPC`。真实 updater 必须清理不完整的可执行文件、拒绝安装、保留显式重试,并在恢复原始写入器后下载通过校验的字节。只拦截私有下载路径,且等待流关闭。这验证文件系统写入失败的处理,不耗尽共享主机卷。
+
+这些证据不能证明真实安装器执行、重启后健康状态、生产 CDN Range 行为、macOS updater 准备或真实卷耗尽。测试服务器不发布任何内容,也不读取云端凭据;生产分发和签名已安装版本升级仍需独立验收。
diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml
similarity index 55%
rename from .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml
rename to .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml
index 7d4eb35fa1..6ebebc30d8 100644
--- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml
@@ -1,6 +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
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
+2026-09-14-desktop-installed-update-journal.md: 2a0ecbde408d1207102ba206ada333de6d123711
+2026-09-14-desktop-installed-update-journal.zh.md: 129efa7c123b6945e857e55e3632b18850ceb67d
diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
new file mode 100644
index 0000000000..2a0ecbde40
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md
@@ -0,0 +1,29 @@
+# Agent Note: Preserve installed Desktop update evidence across restarts
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-installed-update-journal.zh.md)
+
+## Problem
+
+An installed update replaces application files and exits the process that observed the download. Terminal output alone cannot connect a failed transfer, explicit retry, installation authorization, and the next version's startup. Raw updater diagnostics can contain private URLs or credentials.
+
+## Decision
+
+The Desktop main entry accepts an opt-in absolute `DSH_DESKTOP_UPDATE_JOURNAL_DIR`. Qualification packages must retain the same external directory across versions. Each process exclusively creates a separate JSONL file and flushes whitelisted state and action records before continuing. The installed version, PID, sequence, and UTC time identify records. Integer progress changes limit repeated writes; raw errors, URLs, request headers, and chat content are omitted. Known error tokens produce fixed classifications instead of copied diagnostics.
+
+Without the variable, the journal is disabled. Explicit qualification storage failures propagate rather than silently claiming complete evidence. Startup and workspace readiness are observations, not proof of installer success or preserved user data. The [local qualification decision](2026-09-10-desktop-local-updater-qualification.md) remains active: this journal does not supersede its isolation, intercepted-installation limits, or hardware restrictions.
+
+The [installed-run tool](../../../../apps/desktop/scripts/installed-update-qualification.ts) allocates a private test manifest without credentials or remote operations. Its read-only inspector requires an ordered failure, manual retry, readiness, confirmation, and quit in one original process, followed by successor startup and workspace readiness. Unknown fields, partial records, sequence gaps, and mixed process identities are rejected. Even complete recorded flow retains independent operator checks for publication timing, network recovery, installer completion, and preserved data; a collection of startup logs cannot substitute for those observations.
+
+## Alternatives considered
+
+**Keep only terminal output or installation-directory files.** Installation replaces application files, and the new process can outlive the terminal. The evidence directory remains outside the installation tree.
+
+**Persist arbitrary logs and redact known secrets.** Unknown authentication values and private paths cannot be exhaustively enumerated. Field whitelisting retains less detail but excludes those strings.
+
+## Consequences
+
+Journal collection snapshots validated bytes once and derives both retained files and milestone reports from those bytes. Re-reading a changing source separately for each output could report observations absent from the saved logs. Each collection uses a new directory and hashes its files; earlier collections and source journals remain unchanged. Fixed diagnostic values are checked at the file reader, not only at the writer, before bytes can enter a collection. Invalid input refuses collection; storage failure preserves partial evidence rather than claiming completion. A complete milestone sequence still leaves operator acceptance pending.
+
+Journal and main-entry tests cover persistence, retry milestones, separate version files, omitted private fields, and unavailable storage. Installed restart, installation completion, data preservation, and network fault injection still require operator qualification. Logs have no automatic deletion; the operator owns retention and must reject missing or incomplete evidence. The journal never clears signing locks or authorizes installation.
diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md
new file mode 100644
index 0000000000..129efa7c12
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 跨重启保留已安装 Desktop 的更新证据
+
+Status: implemented
+
+[English](2026-09-14-desktop-installed-update-journal.md) | 中文
+
+## 问题
+
+已安装应用的更新会替换应用文件,并退出观测下载的进程。仅有终端输出无法串联传输失败、显式重试、安装授权与下一版本启动。原始 updater 诊断可能包含私有 URL 或凭据。
+
+## 决策
+
+Desktop 主入口接受显式启用的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`。验收包必须在不同版本间保留同一个外部目录。每个进程独占创建单独的 JSONL 文件,并在继续执行前刷新白名单内的状态与操作记录。已安装版本、PID、序号和 UTC 时间标识记录。整数进度变化限制重复写入;原始错误、URL、请求头和聊天内容均被省略。已知错误标记产生固定分类,不复制诊断文本。
+
+未设置该变量时不启用日志。显式启用的验收存储失败会向上传播,不会静默声称证据完整。启动和工作区就绪只是观测结果,不能证明安装成功或用户数据保留。[本地验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:本日志不取代其隔离要求、安装拦截的局限或硬件限制。
+
+[安装版批次工具](../../../../apps/desktop/scripts/installed-update-qualification.ts)创建独立 test 清单,不读取凭据或执行远程操作。其只读检查器要求同一原进程内按序记录失败、手动重试、就绪、确认和退出,再记录后继版本启动与工作区就绪。未知字段、不完整记录、序号缺口与混合进程身份均被拒绝。即使记录流程完整,也保留发布时间、网络恢复、安装器完成和数据保留的独立人工检查;一组启动日志不能代替这些观测。
+
+## 考虑过的替代方案
+
+**仅保留终端输出或安装目录内的文件。** 安装会替换应用文件,新进程也可能在终端结束后继续运行。证据目录保留在安装目录树之外。
+
+**保存任意日志并对已知秘密脱敏。** 未知认证值和私有路径无法穷尽枚举。字段白名单保留的细节较少,但排除了这些字符串。
+
+## 结果
+
+日志收集只读取一次通过验证的字节,并从这些字节生成保存文件和里程碑报告。分别为各输出重读变化中的源文件,可能报告保存日志中不存在的观测。每次收集使用新目录并记录文件哈希;旧收集和源日志不变。在字节进入收集目录前,文件读取器也检查固定诊断值,而非仅依赖写入器。输入无效时拒绝收集;存储失败保留部分证据,不宣称完成。完整的里程碑序列仍将人工验收保持为待确认。
+
+日志与主入口测试覆盖持久化、重试标记、不同版本的独立文件、私有字段省略和存储不可用。已安装应用重启、安装完成、数据保留与网络故障注入仍需人工验收。日志不会自动删除;操作者负责保留记录,并必须拒绝证据缺失或不完整的结果。日志不会清除签名锁定或授权安装。
diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml
new file mode 100644
index 0000000000..0c1184b114
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
+2026-09-14-desktop-installed-update-materials.md: 1813c6d9bbdf12697e32f7440e4b654a33bb2384
+2026-09-14-desktop-installed-update-materials.zh.md: b92bb70b32e6549b8f2aed48f6aa9b2b8364fe54
diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
new file mode 100644
index 0000000000..1813c6d9bb
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
@@ -0,0 +1,49 @@
+# Agent Note: Isolate installed-update materials from releases and user data
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-installed-update-materials.zh.md)
+
+## Problem
+
+An installed-update walkthrough needs two increasing versions with a shared application identity. Reusing normal release paths can expose test metadata to unrelated clients, while storing test state inside an installation directory loses evidence during replacement. A successful copy does not establish that a signed installer exists.
+
+## Decision
+
+Version derivation follows the [release version decision](../process/2026-09-16-desktop-release-version-derivation.md); this record continues to govern material, application identity, and user-data isolation.
+
+The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate derived test versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt.
+
+The generated bootstrap validates the installed package identity before importing production main. Each version assigns the same application-data subdirectory to its test Electron profile, Harness home, and external journals. Environment values inherited from the first launch do not determine those paths. The manifest reader rejects changed test destinations, identities, and directory locations. The [operator guide](../../../../apps/desktop/tests/installed-update/README.md) distinguishes source preparation, private runtime copies, and signed installed-app qualification.
+
+The [journal decision](2026-09-14-desktop-installed-update-journal.md) and [local updater qualification decision](2026-09-10-desktop-local-updater-qualification.md) remain active: material preparation neither proves an installed upgrade nor changes hardware authorization. Signing, upload, installation, and network mutation are absent from these preparation tools.
+
+## Alternatives considered
+
+**Retag the repository's package manifests between builds.** That changes the working release version and can affect other tasks or release commands. Private runtime copies and package-specific metadata keep the source checkout's versions unchanged.
+
+**Use the production identity or terminal-only data overrides.** Production identity shares registration and data with ordinary installs; terminal overrides may not survive installer-triggered startup. A private identity and packaged bootstrap retain isolation across versions.
+
+**Resume by overwriting a partially prepared version.** That can mix source generations and discard failure evidence. An existing output or preparation record refuses reuse; a clean attempt requires a new run.
+
+**Infer upload failure from a denied bucket-configuration query.** Object access and bucket configuration have separate permissions. An added preflight can reject credentials whose object operations work. Diagnose the exact API and compare existing successful evidence before asking for broader permissions; no attempted PUT means no observed upload failure.
+
+## Consequences
+
+Feed publication reuses a completed binary-upload result whose retained plan exactly matches the current verified distribution, including run identity, version, destinations, sizes, and hashes. It records the receipt and plan hashes and reads only the feed remotely. Initial binary upload retains full public-byte verification. Repeating that transfer for every feed write adds latency and traffic without changing immutable artifacts; the retained receipt establishes prior delivery, not a guarantee against later external deletion. Missing, failed, or mismatched receipts refuse publication rather than silently triggering another large download.
+
+The separate [publication entry](../../../../apps/desktop/tests/installed-update/publication/README.md) defaults to local receipt and byte verification. Binary upload cannot advertise a version; successor feed publication requires original-version startup evidence and a matching previous feed. The test-only transport disables SDK write retries, requires an authoritative unversioned-bucket check before immutable-object protection, and verifies exact public bytes. A local lock does not provide distributed exclusion: one operator must own the mutable feed across machines. Each authorized operation retains independent stage records; uncertain writes require inspection, not automatic repetition. Matching objects can be reconciled without replacement. In-memory sequencing and substituted HTTP transport tests do not establish real COS publication or installed-app startup.
+
+The separate [operator packaging entry](../../../../apps/desktop/scripts/package-installed-update.ts) checks the durable signing interlock before loading credentials and rechecks after interactive confirmation. Each execution owns one version and a never-reused packaging directory. It uses the existing supervisor with a 15-minute stage deadline, retains redacted output and selected source/tool hashes, and compares those inputs after building. Upload credentials never reach the builder; publication and installation are absent. Inert subprocess tests cover refusal, redaction, retained failure, and no reuse; actual signed output and package verification remain separate evidence. An overall deadline cannot limit CSP-internal PIN attempts or authorize unattended signing.
+
+Application preparation freezes built main/preload modules, renderer files, and the generated bootstrap once per run, records SHA-256 values, and rejects changed, missing, or additional frozen files. The [qualification builder configuration](../../../../apps/desktop/scripts/installed-update-builder.ts) reuses the ordinary configuration factory with the selected private runtime, so resource verification and signing use the same runtime version. It preserves installer hooks and publisher checks while selecting a shared test identity, package entry, and fixed test feed. Dependency trees and build tools remain separate mutable inputs and require final-build records. Configuration validation uses the pinned builder with a substituted signer, not hardware or installed-app evidence.
+
+The read-only [distribution planner](../../../../apps/desktop/scripts/installed-update-distribution.ts) checks each version's generated YAML against local installer bytes and separates immutable binary objects from one mutable feed URL. It reconstructs feed fields from validated values instead of preserving arbitrary input fields. The output explicitly carries file-integrity-only status and no publication authority; credentials, signatures, package identity, and version 1 startup are outside this check.
+
+The separate [signature reader](../../../../apps/desktop/scripts/installed-update-signature.mjs) requires the real updater verifier, valid Authenticode and timestamp attributes, and unchanged executable bytes. It runs verification children with scrubbed credentials and without inherited PowerShell module paths; incompatible module overrides cannot be treated as successful verification. The publisher comes from the trusted public certificate rather than remote YAML. Read-only verification never calls SignTool, accesses the signing token, or executes the inspected file. Signature success cannot establish package contents or a completed installation.
+
+The [package verifier](../../../../apps/desktop/scripts/verify-installed-update-package.ts) extracts the signed installer itself rather than trusting a neighboring unpacked directory. Its archived identity, update configuration, frozen application files, and Harness runtime must match the retained run, and its mandatory test policy must be valid. It reads runtime files from ASAR, accepts only electron-builder's dependency-manifest transformation, and checks executable signatures in `app.asar.unpacked`. Unsigned runtime executable changes cannot pass through a regenerated inventory alone. Independent immutable records preserve each check's stages and failures. Final input hashes prevent an installer/feed change from inheriting an earlier result. Real ASAR and runtime fixtures cover mismatches while substituting only external verification and archive processes; actual signed packages still require their own receipts. Installer registration and installed-app behavior are outside this file inspection and remain operator checks.
+
+The [network fault procedure](../../../../apps/desktop/tests/installed-update/network/README.md) binds a program-specific outbound rule to the verified original executable hash and random run identity. It rejects changed bytes before creation and after confirmation, and removes only a matching owned rule. Recovery remains available if the executable disappears. The operator controls both mutations; stages persist before and after each operation, and failures stop without retry. Firewall-cmdlet substitutes exercise the real script, but a rule's recorded presence or absence never proves traffic interruption or successful download recovery. Adapter-wide or VPN changes are excluded because they can disconnect the operator's remote control.
+
+Generated-entry tests execute both versions with only Electron's path API substituted and verify the shared directories before main imports. Runtime tests verify dependency retagging, unchanged third-party versions and source bytes, refusal of damaged input, and retained partial failures. Copies consume extra disk space. Source preparation can boot the source runtime, but copied-version startup, final package contents, signatures, publication, and installation require separate evidence; completion receipts name those limits rather than inferring success.
diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md
new file mode 100644
index 0000000000..b92bb70b32
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: 将安装版更新物料与发布及用户数据隔离
+
+Status: implemented
+
+[English](2026-09-14-desktop-installed-update-materials.md) | 中文
+
+## 问题
+
+安装版更新演练需要两个递增版本共用应用身份。复用正常发布路径可能向无关客户端暴露测试元数据,而把测试状态放在安装目录中会在替换时丢失证据。复制成功不能证明已有签名安装包。
+
+## 决策
+
+版本派生遵循[发布版本决策](../process/2026-09-16-desktop-release-version-derivation.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。
+
+[物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。
+
+生成的启动入口在导入生产主程序前验证已安装包身份。两个版本将相同的应用数据子目录用于测试 Electron profile、Harness home 和外部日志。首次启动继承的环境变量不决定这些路径。清单读取器拒绝已改变的 test 目标、身份和目录位置。[人工指南](../../../../apps/desktop/tests/installed-update/README.zh.md)区分源码准备、独立运行时副本和签名安装版验收。
+
+[日志决策](2026-09-14-desktop-installed-update-journal.zh.md)与[本地 updater 验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:物料准备既不证明已安装应用升级通过,也不改变硬件授权。这些准备工具不包含签名、上传、安装或网络改动。
+
+## 考虑过的替代方案
+
+**在两次构建之间修改仓库包清单的版本。** 这会改变工作区发布版本,可能影响其他任务或发布命令。独立运行时副本与包专属元数据保持源码工作区版本不变。
+
+**使用生产身份或仅通过终端覆盖数据目录。** 生产身份与普通安装共享注册和数据;终端覆盖不一定在安装器触发启动时保留。独立身份与包内启动入口维持跨版本隔离。
+
+**覆盖准备到一半的版本来继续执行。** 这可能混合不同源码代际并丢弃失败证据。已有输出或准备记录拒绝复用;全新尝试需要新批次。
+
+**从桶配置查询被拒绝推断上传失败。** 对象访问与桶配置使用不同权限。新增前置检查可能拒绝对象操作原本可用的凭据。申请扩大权限前,先定位准确 API 并对照已有成功证据;没有尝试 PUT,就没有观察到上传失败。
+
+## 结果
+
+feed 发布复用已完成的二进制上传结果,其保留计划必须与当前已验证分发计划完全匹配,包括批次身份、版本、目标地址、大小和哈希。发布记录回执及计划哈希,仅在远端读取 feed。首次二进制上传保留完整公网字节验证。每次写 feed 都重复传输会增加延迟和流量,却不改变不可变物料;保留回执证明此前分发成功,不保证对象此后未被外部删除。回执缺失、失败或不匹配时拒绝发布,不隐式触发另一次大文件下载。
+
+独立的[发布入口](../../../../apps/desktop/tests/installed-update/publication/README.zh.md)默认仅验证本地回执与字节。二进制上传不能公布版本;后继版本 feed 发布要求原版本启动证据及匹配的旧 feed。test 专用传输禁用 SDK 写入重试,在使用不可变对象保护前要求权威查询确认存储桶未启用版本控制,并验证准确的公网字节。本地锁不提供分布式排他:必须由一个操作者跨机器独占可变 feed。每次授权操作保留独立阶段记录;写入结果不确定时要求检查,不能自动重试。匹配对象可核对而不替换。内存顺序测试和替代 HTTP 传输测试不证明真实 COS 发布或已安装应用启动通过。
+
+独立的[人工打包入口](../../../../apps/desktop/scripts/package-installed-update.ts)在加载凭据前检查持久签名保护锁,并在交互确认后再次检查。每次执行只拥有一个版本和一个不复用的打包目录。它使用现有监督程序与 15 分钟阶段期限,保留脱敏输出和选定的源码/工具哈希,并在构建后比较这些输入。上传凭据绝不传给构建器;入口不包含发布和安装。无功能子进程测试覆盖拒绝、脱敏、失败保留与不复用;实际签名产物和包验证仍需独立证据。整体期限不能限制 CSP 内部 PIN 尝试,也不能授权无人值守签名。
+
+应用准备按批次冻结一次已构建的主程序/预加载模块、界面文件和生成的启动入口,记录 SHA-256,并拒绝冻结文件被修改、缺失或额外增加。[验收打包配置](../../../../apps/desktop/scripts/installed-update-builder.ts)将选定的独立运行时传给常规配置工厂,使资源校验与签名使用同一运行时版本。它保留安装器钩子和发布者检查,同时选择共享测试身份、包入口和固定 test feed。依赖树和构建工具仍是独立的可变输入,需要最终构建记录。配置验证使用固定版本构建器和替代签名器,不是硬件或已安装应用证据。
+
+只读[分发规划器](../../../../apps/desktop/scripts/installed-update-distribution.ts)逐版本核对生成的 YAML 与本地安装包字节,并将不可变二进制对象与一个可变 feed URL 分开。它从已验证的值重建 feed 字段,不保留任意输入字段。输出明确标识仅验证文件完整性、没有发布授权;凭据、签名、包身份与版本 1 启动不属于本检查。
+
+独立的[签名读取器](../../../../apps/desktop/scripts/installed-update-signature.mjs)要求真实 updater 验签、有效 Authenticode 与时间戳属性,以及未变的可执行文件字节。验签子进程移除凭据和继承的 PowerShell 模块路径;不兼容模块覆盖不能被当作验签成功。发布者来自可信公钥证书,而非远端 YAML。只读检查绝不调用 SignTool、访问签名令牌或执行被检查文件。签名通过不能证明包内容或安装完成。
+
+[安装包检查器](../../../../apps/desktop/scripts/verify-installed-update-package.ts)解开签名安装包本身,而非信任相邻的解包目录。归档内身份、更新配置、冻结应用文件和 Harness 运行时必须与保留批次一致,测试强更策略也必须有效。检查器从 ASAR 读取运行时文件,只接受 electron-builder 对依赖 manifest 的转换,并在 `app.asar.unpacked` 中检查可执行文件签名。未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/feed 变化继承旧结果。真实 ASAR 和运行时夹具覆盖不符情况,仅替换外部验签和归档进程;实际签名包仍需各自回执。安装器注册和已安装应用行为不属于文件检查,保留为人工检查项。
+
+[网络故障步骤](../../../../apps/desktop/tests/installed-update/network/README.zh.md)将指定程序的出站规则绑定到已验证原版本可执行文件哈希及随机批次身份。它在创建前和确认后拒绝变化的字节,仅移除匹配的自有规则。可执行文件消失后仍可恢复。两次改动均由操作者控制;每次操作前后持久记录阶段,失败即停、不重试。防火墙命令替身执行真实脚本,但记录规则存在或不存在绝不证明流量中断或下载恢复成功。不改网卡或 VPN,因为这可能断开操作者的远控。
+
+生成入口测试只替换 Electron 路径 API,执行两个版本并在主程序导入前核对共享目录。运行时测试验证依赖版本替换、第三方版本和原始字节不变、损坏输入拒绝以及失败现场保留。副本额外占用磁盘。源码准备可启动原始运行时,但副本版本启动、最终包内容、签名、发布和安装均需独立证据;完成回执说明这些局限,不推断成功。
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml
new file mode 100644
index 0000000000..a928ba5315
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
+2026-09-08-desktop-mandatory-update-api.md: f88216065fe09221c7a14805e4d25fb97a259abc
+2026-09-08-desktop-mandatory-update-api.zh.md: cb3e631040d221b87979b7fcd379c8881dba36bd
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
new file mode 100644
index 0000000000..f88216065f
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md
@@ -0,0 +1,148 @@
+# Agent Note: Desktop mandatory-update API
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-mandatory-update-api.zh.md)
+
+## Problem
+
+Desktop requires mandatory-update decisions when its business server is local and when the user is not signed in. Mobile ordinary-update responses do not define Desktop artifact installation. Backend and Desktop owners need a self-contained protocol with explicit success, blocking, and error semantics, separate from updater artifact selection.
+
+## Proposal
+
+Use a guest-accessible policy endpoint. This document owns its Desktop request and response fields; the [initial update proposal](2026-09-08-desktop-update-policy-and-installation.md) owns client scheduling, UI, release ordering, and installation. Backend integration is in progress; API origins and gateway details are deployment inputs, not evidence of a live service. No sibling repository or local document is needed to interpret this protocol.
+
+### Endpoint and access
+
+The [client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns the implemented polling and blocking UI. This API proposal remains active for backend deployment and live integration; local fixtures do not certify the service.
+
+```http
+GET /api/v0/check_client_update
+```
+
+The query itself must return the complete mandatory response; do not rely on intercepting unrelated business APIs. Preserve Android and iOS behavior without requiring existing mobile versions to send new Desktop fields. Desktop receives mandatory policy only, not ordinary-update prompts, installer metadata, updater target versions, device IDs, installation IDs, or per-installation rollout assignments.
+
+### Request
+
+```http
+GET /api/v0/check_client_update?scenario=launch
+x-client-platform: desktop-win
+x-client-version: 0.1.3-rc.2
+x-client-bundle-id: com.deepseek.dsh
+x-client-locale: zh-CN
+x-client-arch: x64
+x-client-update-channel: nightly
+x-client-bundled-dsh-version: 0.1.3-rc.2
+```
+
+All listed headers are required for Desktop. Values describing installed software come from the application and its release metadata, not editable UI fields.
+
+| Header | Meaning and allowed values |
+|---|---|
+| `x-client-platform` | `desktop-win` or `desktop-mac` |
+| `x-client-version` | Full Desktop SemVer, retaining prerelease identifiers; initially equal to bundled dsh |
+| `x-client-bundle-id` | Application identity, for example `com.deepseek.dsh`; distinguishes Harness from Chat |
+| `x-client-locale` | UI locale, for example `zh-CN`; selects localized content, not region |
+| `x-client-arch` | Windows `x64`; macOS `x64` or `arm64` |
+| `x-client-update-channel` | Initially always `nightly`, independent of version suffix |
+| `x-client-bundled-dsh-version` | Full bundled dsh version from release metadata |
+
+| Query | Client behavior | Initial backend behavior |
+|---|---|---|
+| `scenario` | Carry the query source; `launch` is a startup example | Ignore; final desktop enumeration does not block integration |
+| `region` | Optional; send only when trustworthy region information exists | Ignore; do not infer it from UI locale |
+
+Neither query affects initial policy matching or parameter validation. Changing or omitting either query must not change the decision for otherwise identical client conditions. Lifecycle triggers and query enum values need not have a one-to-one mapping; enumeration and analytics can be agreed before backend use is enabled.
+
+### No mandatory update
+
+```json
+{
+ "code": 0,
+ "msg": "",
+ "data": {
+ "biz_code": 0,
+ "biz_msg": "",
+ "biz_data": null
+ }
+}
+```
+
+This means the current client does not require a mandatory update, not that its installed version is latest. A fresh valid response for the current client conditions may clear a known block. Desktop does not consume mobile ordinary-update payloads in this response.
+
+### Mandatory update
+
+```json
+{
+ "code": 40005,
+ "msg": "Client version too low",
+ "data": {
+ "show_content": {
+ "title": "请更新 DeepSeek Harness",
+ "detail": "当前版本已停止支持,请下载并安装新版本。"
+ },
+ "desktop_app_link": "https://example.com/harness/download"
+ }
+}
+```
+
+The example URL is a placeholder, not an approved deployment destination. Desktop uses the flattened `data` fields, with no `alt_app` or `biz_data` wrapper. No Desktop has shipped, so no compatibility parser for the earlier nested proposal is required. Mobile `alt_app`, Android links, and iOS app identifiers retain their existing format.
+
+| Field | Requirement |
+|---|---|
+| `code` | `40005` is the response-body code, not an HTTP status |
+| `msg` | Diagnostics only, not dialog copy |
+| `data.show_content.title` | Required localized plain-text title |
+| `data.show_content.detail` | Required localized plain-text detail |
+| `data.desktop_app_link` | Required HTTPS page matching product, platform, architecture, and channel; must satisfy the client allowlist |
+
+Do not add `mode`, `force_update`, `show_key`, `target_version`, button copy, or updater metadata initially. `40005` determines that upgrading is mandatory; updater metadata determines the actual version and package. The client does not perform an additional mandatory-target version check from this API. Client state selects localized actions, and every download still requires user action. The page remains a fallback after in-app updater integration. The HTTP status mapping follows gateway integration and must be confirmed; the gateway must preserve the JSON body instead of replacing it with generic text or HTML.
+
+### Errors and policy matching
+
+Missing required headers, invalid SemVer, and unsupported platform/architecture/channel combinations return explicit parameter errors, not no-force success or a mandatory policy. Prefer the existing business meanings of `biz_code = 1` for a missing version and `biz_code = 2` for an invalid version; final error allocation belongs to the backend. Service failures must not masquerade as success because success may remove an existing block.
+
+Match application identity, platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before.
+
+### Policy publication and current decisions
+
+Every active policy requires deterministic matching conditions, localized title/detail, and a valid destination. The server evaluates current client conditions on every query. These are server configuration requirements, not extra response fields or an artifact revocation mechanism.
+
+- Publish and verify a higher release that resolves the mandatory requirement before enabling its policy; the matching page and platform package must be available.
+- For clients with in-app mandatory updating, publish the resolving version to their updater feed before activating the mandatory requirement. Release owners coordinate this ordering; the API does not supply a separate installer target, artifact eligibility response, or ordinary-update rollout.
+- Match links to the product, platform, architecture, and channel; reject policy activation when content or an allowed HTTPS destination is missing.
+- Return no-force success when the current client no longer matches, including after upgrading. Continue returning mandatory policy while it still matches.
+
+Handle a defective release by publishing a higher fixed version and updating updater metadata. Do not add a revoked-version list, pre-install artifact-revocation query, or target-version validation to this API. A fresh no-force response can clear the UI block but does not select, replace, or invalidate an updater package. Artifact hash/signature checks remain required independently of the mandatory decision.
+
+### Capacity and caching
+
+Capacity and rate limits must account for recurring guest requests, client-side coalescing, and failure backoff. Policy delivery does not depend on chat requests or SSE. Online delivery latency depends on polling and connectivity; offline clients cannot receive new policy immediately. Prefer `Cache-Control: no-store`. A gateway must not share responses across client versions, platforms, architectures, or channels based only on URL; future caching must specify all policy/content keys and decision freshness.
+
+## Alternatives considered
+
+**Relying on remote business interception.** Local dsh requests need not reach the remote gateway; an independent guest query is necessary.
+
+**Reusing the mobile nested payload for Desktop.** The backend agreement flattens Desktop fields. Preserve mobile compatibility by platform, without making new Desktop clients support an undistributed nested variant.
+
+**Adding installation identity and ordinary-update metadata.** Initial policy needs neither per-installation rollout nor a second installer feed. Keep artifact discovery in the updater.
+
+## Acceptance criteria
+
+| Case | Expected result |
+|---|---|
+| Unauthenticated Desktop | Query works without business login |
+| Windows x64, macOS x64 and arm64 | Correct platform policy and page |
+| No policy match | `code = 0`, `biz_code = 0`, `biz_data = null` |
+| Policy match | Top-level `40005`, direct content and page under `data` |
+| Changed or omitted query parameters | Same decision for otherwise identical conditions |
+| Equal prerelease Desktop/dsh versions, fixed Nightly | Full SemVer matching, no downgrade or channel-switch requirement |
+| Client no longer matches, including after upgrading | No-force success; does not invalidate updater artifacts |
+| Unavailable resolving release, page, or applicable feed | Policy activation rejected |
+| Invalid required fields or service failure | Explicit error, not false no-force success |
+| Sequential requests from different client conditions | No cross-client cache leakage |
+| Existing mobile requests | Existing requirements and response structure preserved |
+
+## Risks
+
+Backend owners must provide test/production origins, guest gateway access, final HTTP/error-code mapping, limits, real page destinations, allowed domains, fallback locale, and policy configuration ownership. Release owners must coordinate available updater releases and mandatory-policy activation per platform. These inputs remain pending and must not be filled with developer credentials or guessed URLs.
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md
new file mode 100644
index 0000000000..cb3e631040
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md
@@ -0,0 +1,148 @@
+# Agent Note: Desktop 强制更新接口
+
+Status: proposed
+
+[English](2026-09-08-desktop-mandatory-update-api.md) | 中文
+
+## 问题
+
+Desktop 在业务 server 为本地进程、用户尚未登录时也需要获取强制更新判定。移动端普通更新响应不定义 Desktop 产物安装。后端与 Desktop 需要一份自包含协议,明确成功、阻断和错误语义,与 updater 的产物选择分开。
+
+## 提案
+
+使用未登录可访问的策略接口。本文负责 Desktop 请求响应字段,[一期更新提案](2026-09-08-desktop-update-policy-and-installation.zh.md)负责客户端调度、界面、发布顺序与安装。后端正在接入;API 地址与网关细节是部署输入,不表示服务已经可用。理解本协议不依赖旁边的仓库或本机文档。
+
+### 接口与访问
+
+[客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责已实现的轮询和阻塞界面。本接口提案仍负责后端部署与真实联调;本地 fixture(测试前置数据)不能认证服务。
+
+```http
+GET /api/v0/check_client_update
+```
+
+查询接口本身必须返回完整强制响应,不能依赖拦截其他业务接口。保持 Android 与 iOS 既有行为,不要求旧移动端补传 Desktop 新字段。Desktop 仅接收强制策略,不接收普通更新提示、安装包元数据、updater 目标版本、设备 ID、安装实例 ID 或按实例灰度分配。
+
+### 请求
+
+```http
+GET /api/v0/check_client_update?scenario=launch
+x-client-platform: desktop-win
+x-client-version: 0.1.3-rc.2
+x-client-bundle-id: com.deepseek.dsh
+x-client-locale: zh-CN
+x-client-arch: x64
+x-client-update-channel: nightly
+x-client-bundled-dsh-version: 0.1.3-rc.2
+```
+
+以上 header 对 Desktop 均为必填。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。
+
+| Header | 含义与取值 |
+|---|---|
+| `x-client-platform` | `desktop-win` 或 `desktop-mac` |
+| `x-client-version` | 完整 Desktop SemVer,保留预发布标识;一期等于内置 dsh |
+| `x-client-bundle-id` | 应用身份,例如 `com.deepseek.dsh`,区分 Harness 与 Chat |
+| `x-client-locale` | UI 语言,例如 `zh-CN`;选择本地化内容,不代表区域 |
+| `x-client-arch` | Windows 为 `x64`;macOS 为 `x64` 或 `arm64` |
+| `x-client-update-channel` | 一期固定 `nightly`,独立于版本后缀 |
+| `x-client-bundled-dsh-version` | 发布元数据中的完整内置 dsh 版本 |
+
+| Query | 客户端行为 | 后端一期行为 |
+|---|---|---|
+| `scenario` | 携带查询来源;`launch` 是启动示例 | 忽略;桌面最终枚举不阻塞接入 |
+| `region` | 可选;仅在有可信区域信息时携带 | 忽略;不从 UI 语言推断 |
+
+两个 query 都不参与一期策略匹配或参数校验。其他客户端条件相同时,改变或省略任一 query 不得改变判定。生命周期触发点与 query 枚举不必一一对应;后端启用使用前再约定枚举与统计。
+
+### 无需强制更新
+
+```json
+{
+ "code": 0,
+ "msg": "",
+ "data": {
+ "biz_code": 0,
+ "biz_msg": "",
+ "biz_data": null
+ }
+}
+```
+
+这表示当前客户端无需强制更新,不表示已安装版本最新。针对当前客户端条件的新鲜有效响应可以清除已有阻断。Desktop 不消费此响应中的移动端普通更新载荷。
+
+### 需要强制更新
+
+```json
+{
+ "code": 40005,
+ "msg": "Client version too low",
+ "data": {
+ "show_content": {
+ "title": "请更新 DeepSeek Harness",
+ "detail": "当前版本已停止支持,请下载并安装新版本。"
+ },
+ "desktop_app_link": "https://example.com/harness/download"
+ }
+}
+```
+
+示例 URL 是占位符,不是获准部署的目标地址。Desktop 使用拍平的 `data` 字段,不包裹 `alt_app` 或 `biz_data`。尚无已分发 Desktop,因此不需要兼容较早嵌套提案的解析器。移动端 `alt_app`、Android 链接与 iOS 应用标识保持既有格式。
+
+| 字段 | 要求 |
+|---|---|
+| `code` | `40005` 是响应体错误码,不是 HTTP 状态 |
+| `msg` | 仅用于诊断,不作弹窗文案 |
+| `data.show_content.title` | 必填,本地化纯文本标题 |
+| `data.show_content.detail` | 必填,本地化纯文本正文 |
+| `data.desktop_app_link` | 必填,匹配产品、平台、架构与通道的 HTTPS 页面,必须符合客户端允许列表 |
+
+一期不增加 `mode`、`force_update`、`show_key`、`target_version`、按钮文案或 updater 元数据。`40005` 决定必须升级,实际版本与安装包由 updater 元数据决定。客户端不依据本接口另做强制目标版本校验。客户端状态决定本地化操作,每次下载仍需用户操作。接入应用内 updater 后,页面仍作为兜底。HTTP 状态映射按网关联调确认;网关必须保留 JSON 错误体,不能替换为通用文本或 HTML。
+
+### 错误与策略匹配
+
+缺少必填 header、非法 SemVer 或不支持的平台/架构/通道组合返回明确参数错误,不能伪装为无强制成功或强制策略。优先沿用 `biz_code = 1` 表示缺少版本、`biz_code = 2` 表示版本非法的既有业务含义;最终错误码分配由后端负责。服务异常不得伪装成成功,因为成功可能解除已有阻断。
+
+按应用身份、平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。
+
+### 策略发布与当前判定
+
+每条生效策略需要可确定的匹配条件、本地化标题正文和有效目标页面。服务端每次查询均判定当前客户端条件。这些是服务端配置要求,不增加响应字段,也不是安装包撤回机制。
+
+- 启用策略前发布并验证能解除强制要求的更高版本,确保匹配的页面与平台安装包可用。
+- 对启用应用内强制更新的客户端,先向其 updater feed 发布能解除要求的版本,再启用强制要求。发布负责人协调该顺序;API 不提供独立安装目标、产物适用性响应或普通更新灰度。
+- 链接匹配产品、平台、架构与通道;缺少文案或获准 HTTPS 目标时拒绝启用策略。
+- 当前客户端不再命中时返回无强制成功响应,包括升级后;仍命中时持续返回强制策略。
+
+问题版本通过发布更高修复版本并更新 updater 元数据处理。不向本接口增加作废版本清单、安装前产物撤回查询或目标版本校验。新鲜的无强制响应可以解除 UI 阻断,但不选择、替换或作废 updater 安装包。产物哈希/签名校验仍独立于强制判定执行。
+
+### 容量与缓存
+
+容量与限流需覆盖持续未登录请求,并考虑客户端合并与失败退避。策略触达不依赖聊天请求或 SSE(Server-Sent Events)。在线触达延迟受轮询和网络影响,离线客户端无法立即获知新策略。建议 `Cache-Control: no-store`。网关不得仅按 URL 跨客户端版本、平台、架构或通道共用响应;后续缓存必须定义所有策略与文案维度和判定新鲜度。
+
+## 考虑过的替代方案
+
+**依赖远程业务拦截。** 本地 dsh 请求不一定到达远程网关,需要独立的未登录查询。
+
+**Desktop 复用移动端嵌套载荷。** 后端约定拍平 Desktop 字段。按平台保留移动端兼容,不让新 Desktop 支持从未分发的嵌套变体。
+
+**增加安装身份与普通更新元数据。** 初期策略不需要按安装实例灰度或第二个安装器 feed。产物发现继续由 updater 负责。
+
+## 验收标准
+
+| 用例 | 预期结果 |
+|---|---|
+| 未登录 Desktop | 无需业务登录即可查询 |
+| Windows x64、macOS x64 和 arm64 | 正确的平台策略与页面 |
+| 不命中策略 | `code = 0`、`biz_code = 0`、`biz_data = null` |
+| 命中策略 | 顶层 `40005`,文案与页面直接位于 `data` 下 |
+| 改变或省略 query 参数 | 其他条件相同时判定一致 |
+| 相等的 Desktop/dsh 预发布版本、固定 Nightly | 完整 SemVer 匹配,不要求降级或切换通道 |
+| 客户端不再命中,包括升级后 | 返回无强制成功,不作废 updater 产物 |
+| 可解除要求的发布、页面或适用 feed 不可用 | 拒绝启用策略 |
+| 必填字段非法或服务失败 | 明确错误,不伪装为无强制成功 |
+| 不同客户端条件连续请求 | 不跨客户端串用缓存 |
+| 现有移动端请求 | 保持既有要求与响应结构 |
+
+## 风险
+
+后端需提供测试/生产地址、未登录网关访问、最终 HTTP/错误码映射、限流、真实页面、允许域名、兜底语言和策略配置负责人。发布负责人需逐平台协调 updater 版本可用与强制策略启用。这些输入仍待提供,不得用开发者凭据或猜测 URL 填充。
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml
new file mode 100644
index 0000000000..7119122de4
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
+2026-09-08-desktop-update-extensions.md: 6c8a2ebe4a38809f6c3a7fb4741b3520111402c6
+2026-09-08-desktop-update-extensions.zh.md: 06096c8b28136ab2e22325857021926e06b3b17e
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
new file mode 100644
index 0000000000..6c8a2ebe4a
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md
@@ -0,0 +1,113 @@
+# Agent Note: Deferred Desktop update extensions
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-update-extensions.zh.md)
+
+## Problem
+
+Frequent releases may eventually benefit from independently revised Desktop packages, a stable subscription, next-launch installation, or replacing an older pending update. These choices introduce additional release and installer states that are not needed to ship the initial fixed-Nightly, explicitly approved installation flow.
+
+## Proposal
+
+Keep these candidates separate from the [initial update design](2026-09-08-desktop-update-policy-and-installation.md). None is an initial acceptance requirement or authorization to change dependencies. Product approval and platform qualification are required before implementation. The [mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) remains independent; automatic installation does not add installer fields to it.
+
+### Independent revisions
+
+If Desktop-only fixes become necessary on a prerelease line, evaluate the reserved `.dsk.N` format. Keep full SemVer comparison so introducing it does not require a custom version parser. The intended ordering is:
+
+```text
+0.1.3-rc.2
+< 0.1.3-rc.2.dsk.1
+< 0.1.3-rc.2.dsk.2
+< 0.1.3-rc.3
+```
+
+Such a release would record Desktop `0.1.3-rc.2.dsk.1` separately from bundled dsh `0.1.3-rc.2`. Introduce independent `desktopVersion`, `bundledDshVersion`, and `releaseChannel` records only with corresponding build, runtime descriptor, profile reconciliation, publisher, and backend-policy validation. The initial build continues to require equal versions.
+
+Do not use `0.1.3-dsk.1` to repair stable `0.1.3`: SemVer orders it below the stable release. Stable fixes require a higher shared dsh/Desktop patch version, such as `0.1.4`. Independent prerelease revisions are an exceptional release path, not a routine per-build counter.
+
+### Stable subscription and channel switching
+
+| User option | Feed | Release contents |
+|---|---|---|
+| Stable | `latest.yml` / `latest-mac.yml` | Stable only |
+| Nightly | `nightly.yml` / `nightly-mac.yml` | Alpha, rc, and stable |
+
+The [initial publication rule](2026-09-08-desktop-update-policy-and-installation.md) adds the stable feed when shared stable releases exist, without switching fixed-Nightly clients. A user-facing stable subscription and selector remain deferred. If enabled, persist the selected channel; never infer it from the installed version. Preserve `allowDowngrade = false` when assigning the updater channel. Moving from Nightly to stable keeps a higher installed Nightly until a higher stable version exists, without downgrading Desktop or bundled dsh.
+
+Channel switching may immediately check; download behavior must follow the approved setting. Ignore old-channel responses and reconfirm downloaded artifacts against the new channel before installation. A channel change does not clear mandatory blocking without a valid policy response for the new conditions. Display a persistent Nightly explanation under the selector, with candidate Chinese copy “提前体验新功能,版本可能不稳定。”; hide it after selecting stable, while separately explaining any wait for a higher stable version.
+
+Design compatibility only for Desktop packages actually distributed. If a future migration has real clients reading `alpha.yml` or `rc.yml`, publish a higher bridge version to the old feed and Nightly until the old entry can be retired. No such bridge is required initially.
+
+### Automatic-installation setting
+
+The candidate setting is automatic installation, potentially enabled by default only after product approval and measured platform behavior. Candidate Chinese helper text is “关闭后仍会提示更新,需手动升级。” If next-launch installation is not acceptable, evaluate a download-only setting and retain explicit installation confirmation.
+
+| Setting | Newer version discovered | Later complete app launch |
+|---|---|---|
+| Enabled | Download and prepare automatically, including discovery from a manual check | Apply a valid pending update when installation conditions hold |
+| Disabled | Continue checking and prompting; wait for explicit download | Do not install automatically; keep the manual path |
+
+Automatic download and installation in this table are deferred product choices; the initial release requires user action for every download and separate installation approval. Turning a future automatic setting off prevents new automatic downloads and installations; an active download may complete without a new cancel button. Determine whether platform-staged installation can be cancelled before promising immediate effect. Never proactively exit a running app or bypass task-impact approval. Preserve initial check triggers and feedback, without adding network-recovery checks or unsolicited global indicators.
+
+### Next-launch installation
+
+Target a complete process launch, not reopening a window or reloading the renderer. Prefer installation before restoring business tasks or starting local dsh; surviving managed resources require explicit shutdown approval. A cached ZIP or installer is not proof of completed native preparation. Surface extraction, verification, and preparation honestly without fabricated progress.
+
+Use the already prepared target instead of delaying launch for a fresh package download. A bounded check may discover a newer candidate and defer this installation while the current app opens, but only if the platform still permits abandoning the staged target. Native installer handoff may prevent cancellation or retargeting; never simulate it by deleting native staging directories. Measure download-to-ready, click-to-exit, replacement, and new-version profile reconciliation and Host startup separately.
+
+### Updater dependency qualification
+
+The initial dependency declaration remains in [Desktop package metadata](../../../../apps/desktop/package.json). A coordinated electron-builder 27 / electron-updater 7 migration is an evaluation candidate, not a dependency bump authorized here. The following API and platform items require verification against the selected release and real packages; this proposal does not claim they have been exercised locally.
+
+| Candidate item | Required qualification |
+|---|---|
+| `autoInstallEvent` | Verify `manual`, `onQuit`, and `onNextLaunch`; migration alone must retain manual installation |
+| Object-form `quitAndInstall` | Verify `isSilent` and `isForceRunAfter`, plus macOS-specific semantics |
+| Next-launch cache | Re-fetch metadata and validate artifact hashes and platform signatures before installing |
+| Windows per-user NSIS | Verify next-launch behavior without UAC; do not assume per-machine installs are silent |
+| Windows session ending | Verify shutdown/restart/logoff does not launch unsafe on-quit installation; NSIS is not assumed atomic |
+| macOS Squirrel staging | Verify native preparation, quit/relaunch replacement, signing, notarization, and both architectures |
+| Metadata and security defaults | Inspect modern `files[]`, legacy `path`/`sha512` consumers, `sha2` retirement, and NSIS Web Installer defaults |
+| Build-tool migration | Verify Node engine requirements, including the candidate 22.12 floor, native ESM, configuration, signing hooks, and publisher compatibility |
+| Distributed old clients | Exercise their real feeds and payloads before changing metadata; do not infer compatibility from dependency version alone |
+
+### Multiple pending versions
+
+The candidate policy follows the latest eligible version without exposing a package collection to users. Own only one effective download/install target; temporary files do not promise an older installable fallback.
+
+| Situation | Candidate behavior |
+|---|---|
+| A downloading when C is found | Do not repeatedly cancel A; handle newer candidates in a subsequent check |
+| A ready, automatic setting enabled, C found | Prepare C and replace A's install entry with C progress |
+| A ready, automatic setting disabled, C found | Show C; start its download only on user action |
+| D appears during C download | Do not interrupt C; consider D later |
+| Installation and task shutdown approved | Lock the chosen target against background replacement |
+| C preparation fails after replacing A | Keep running the installed app, show error/retry, and do not promise installation of A |
+
+Reconfirm platform support before abandoning a prepared target. Cache identity includes artifact metadata and hashes, not just a version string. Replacement after readiness is application coordination, not an assumed automatic capability of the updater library. It must be separately tested with channel changes, new releases, retries, and installer handoff.
+
+### Cache invalidation and revocation
+
+Reuse verified complete cache; do not promise partial-download resumption or preservation of an older installable package. Publish a higher fix for a defective release and update the feed. Metadata mismatch may invalidate some cache but is not strict revocation: already staged or installing artifacts may still apply. Continuing to run the installed app after failure is different from installing an older cache. Do not implement an independent artifact revocation list or pre-install revocation interception. The mandatory API decides whether upgrading is required, not which updater artifact to install or invalidate.
+
+## Alternatives considered
+
+**Enable every extension in the first release.** This adds unqualified installer and release paths before a Desktop distribution exists. Fixed Nightly and explicit installation provide the smaller initial scope.
+
+**Treat changing a setting as cancellation.** Native staging may already own installation. Product wording and state transitions must reflect verified cancellation capability.
+
+## Acceptance criteria
+
+- Approve each extension independently; retain initial behavior until its acceptance criteria are satisfied.
+- Test independent revision ordering and all consumers of split versions before releasing `.dsk.N`.
+- Verify both channel publication, persistent selection, stale-response rejection, cache revalidation, and no downgrades.
+- Measure real Windows per-user and macOS x64/arm64 update preparation, termination, next launch, native staging, profile reconciliation, and Host startup.
+- Prove the disabled setting's effect on active and staged updates before promising immediate revocation.
+- Cover A-to-C replacement, C failure, intervening D, channel switching, and locked installation targets.
+- Preserve task approval, honest progress, silent automatic-check failures, and recovery to the usable installed app; do not block normal startup on network failure.
+
+## Risks
+
+Native installer ownership limits cancellation and replacement. Faster startup cannot be guaranteed from API availability alone, and metadata replacement is not a universal rollback mechanism. Library API candidates, security defaults, build-tool engines, and old-client compatibility need release-specific verification; none is a verified product capability merely because it appears in this proposal.
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md
new file mode 100644
index 0000000000..06096c8b28
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md
@@ -0,0 +1,113 @@
+# Agent Note: Desktop 更新后续扩展
+
+Status: proposed
+
+[English](2026-09-08-desktop-update-extensions.md) | 中文
+
+## 问题
+
+高频发布后续可能需要 Desktop 独立修订、稳定版订阅、下次启动安装或替换较旧待安装更新。这些选择引入额外发布与安装器状态,初期固定 Nightly、明确确认安装的流程并不需要它们。
+
+## 提案
+
+将这些候选与[一期更新设计](2026-09-08-desktop-update-policy-and-installation.zh.md)分开。它们不是一期验收要求,也不授权修改依赖;实现前需要产品批准与平台验证。[强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md)继续独立,自动安装不向其中增加安装器字段。
+
+### 独立修订
+
+预发布线上确有 Desktop 独立修复需求时,评估预留的 `.dsk.N` 格式。保持完整 SemVer 比较,使引入该格式不需要自定义版本解析器。预期排序为:
+
+```text
+0.1.3-rc.2
+< 0.1.3-rc.2.dsk.1
+< 0.1.3-rc.2.dsk.2
+< 0.1.3-rc.3
+```
+
+这类发布将 Desktop `0.1.3-rc.2.dsk.1` 与内置 dsh `0.1.3-rc.2` 分别记录。只有同时补齐构建、运行时描述、profile 校准、发布工具与后端策略校验,才引入独立的 `desktopVersion`、`bundledDshVersion` 和 `releaseChannel` 记录。一期构建继续要求版本相等。
+
+不能用 `0.1.3-dsk.1` 修复稳定版 `0.1.3`,因为 SemVer 将其排在稳定版之前。稳定版修复需要更高的共用 dsh/Desktop 补丁版本,例如 `0.1.4`。独立预发布修订是特殊发布路径,不是每次构建的常规计数器。
+
+### 稳定版订阅与通道切换
+
+| 用户选项 | Feed | 发布内容 |
+|---|---|---|
+| 稳定版 | `latest.yml` / `latest-mac.yml` | 仅稳定版本 |
+| Nightly | `nightly.yml` / `nightly-mac.yml` | alpha、rc 和稳定版本 |
+
+[一期发布规则](2026-09-08-desktop-update-policy-and-installation.zh.md)在共用稳定版本出现时增加稳定 feed,不切换固定 Nightly 客户端。面向用户的稳定版订阅与选择器仍延后。启用后持久化所选通道,不从已安装版本反推。设置 updater 通道时保持 `allowDowngrade = false`。从 Nightly 切到稳定版时,较高的已安装 Nightly 持续运行,直到出现更高稳定版;不得降级 Desktop 或内置 dsh。
+
+切换通道可以立即检查,下载行为必须遵循获准设置。忽略旧通道响应,安装前按新通道重新确认已下载产物。通道改变不能在缺少新条件有效策略响应时解除强制阻断。选择器下方常驻 Nightly 说明,候选中文为“提前体验新功能,版本可能不稳定。”;选择稳定版后隐藏,必要时另行说明需等待更高稳定版。
+
+仅为实际分发过的 Desktop 安装包设计兼容。后续迁移若存在读取 `alpha.yml` 或 `rc.yml` 的真实客户端,应向旧 feed 与 Nightly 发布更高桥接版本,直到可淘汰旧入口。一期不需要这类桥接。
+
+### 自动安装设置
+
+候选设置为自动安装;仅在产品批准与平台实测后考虑默认开启。候选中文辅助文案为“关闭后仍会提示更新,需手动升级。” 若下次启动安装体验不满足要求,则评估仅自动下载的设置,并保留明确安装确认。
+
+| 设置 | 发现更高版本 | 后续完整启动应用 |
+|---|---|---|
+| 开启 | 自动下载与准备,包含手动检查发现的版本 | 安装条件满足时应用有效待安装更新 |
+| 关闭 | 继续检查与提示,等待明确下载操作 | 不自动安装,保留手动入口 |
+
+表中的自动下载与安装是后续产品选择;一期每次下载都需要用户操作,安装另行确认。关闭未来的自动设置后不发起新的自动下载与安装;在途下载可以完成,不新增取消按钮。承诺立即生效前需确认能否取消平台已暂存安装。不得主动退出使用中的应用或绕过任务影响确认。沿用一期检查触发与反馈,不新增网络恢复检查或未经确认的全局提示。
+
+### 下次启动安装
+
+目标是完整进程启动,不是重开窗口或刷新渲染层。优先在恢复业务任务或启动本地 dsh 前安装;仍存活的受管资源需要明确关闭确认。已缓存 ZIP 或安装器不证明原生准备完成。如实展示解压、校验和准备,不伪造进度。
+
+使用已经准备好的目标,不为下载新包延迟启动。限时检查若发现更高候选,且平台仍允许放弃暂存目标,可以推迟本次安装并打开当前应用;原生安装器接管后可能无法取消或改换目标,不得通过删除原生暂存目录模拟。分别测量下载到就绪、点击到退出、应用替换,以及新版本 profile 校准与 Host 启动耗时。
+
+### updater 依赖验证
+
+一期依赖声明仍由 [Desktop 包元数据](../../../../apps/desktop/package.json)负责。electron-builder 27 / electron-updater 7 配套迁移是评估候选,不是本文授权的依赖升级。以下 API 与平台项目需针对选定版本和真实安装包核验;本提案不宣称已在本机执行验证。
+
+| 候选项目 | 必需验证 |
+|---|---|
+| `autoInstallEvent` | 核验 `manual`、`onQuit` 与 `onNextLaunch`;仅迁移依赖必须保留手动安装 |
+| 对象参数形式的 `quitAndInstall` | 核验 `isSilent`、`isForceRunAfter` 及 macOS 特定语义 |
+| 下次启动缓存 | 安装前重新读取元数据,校验产物哈希与平台签名 |
+| Windows 每用户 NSIS | 验证无需 UAC 的下次启动行为;不假定每机器安装可静默 |
+| Windows 会话结束 | 验证关机/重启/注销不启动不安全的退出安装;不假定 NSIS 原子性 |
+| macOS Squirrel 暂存 | 验证原生准备、退出/重启替换、签名、公证与两种架构 |
+| 元数据与安全默认值 | 检查现代 `files[]`、旧 `path`/`sha512` 消费者、`sha2` 淘汰与 NSIS Web Installer 默认值 |
+| 构建工具迁移 | 核验 Node 引擎要求,包括候选 22.12 下限,以及原生 ESM、配置、签名 hook 与发布工具兼容 |
+| 已分发旧客户端 | 改元数据前实测其真实 feed 与载荷,不仅按依赖版本推断兼容 |
+
+### 多个待安装版本
+
+候选策略跟随最新适用版本,不向用户提供安装包集合管理。同一时刻仅持有一个有效下载/安装目标;暂存文件不承诺可安装的旧版本兜底。
+
+| 场景 | 候选行为 |
+|---|---|
+| A 下载时发现 C | 不反复取消 A,后续检查处理更新候选 |
+| A 就绪、自动设置开启、发现 C | 准备 C,用 C 进度替换 A 安装入口 |
+| A 就绪、自动设置关闭、发现 C | 显示 C,仅在用户操作后开始下载 |
+| C 下载时出现 D | 不打断 C,之后再考虑 D |
+| 已确认安装与任务关闭 | 锁定所选目标,后台不能替换 |
+| 替换 A 后准备 C 失败 | 继续运行已安装应用,显示错误重试,不承诺安装 A |
+
+放弃已准备目标前重新确认平台支持。缓存身份包含产物元数据与哈希,不仅是版本字符串。就绪后的替换是应用协调逻辑,不是假定 updater 库自动提供的能力;必须结合通道变化、新发布、重试与安装器交接单独测试。
+
+### 缓存失效与撤回
+
+复用已校验完整缓存,不承诺部分下载续传或保留旧可安装包。问题版本通过发布更高修复版本并更新 feed 处理。元数据不匹配可能使部分缓存失效,但不是严格撤回:已暂存或安装中的产物仍可能被应用。失败后继续运行已安装应用不同于安装旧缓存。不实现独立的安装包撤回清单或安装前撤回拦截。强制 API 判定是否必须升级,不决定安装或作废哪个 updater 产物。
+
+## 考虑过的替代方案
+
+**首版启用全部扩展。** 在尚无 Desktop 分发时增加未经验证的安装与发布路径。固定 Nightly、明确确认安装提供较小一期范围。
+
+**将修改设置视作取消。** 原生暂存可能已经持有安装操作。产品文案与状态转换必须反映经过验证的取消能力。
+
+## 验收标准
+
+- 独立批准每项扩展;满足对应验收前保持一期行为。
+- 发布 `.dsk.N` 前测试独立修订排序及所有版本拆分消费者。
+- 验证两个通道发布、选择持久化、拒绝陈旧响应、重新校验缓存和禁止降级。
+- 实测 Windows 每用户安装与 macOS x64/arm64 的更新准备、终止、下次启动、原生暂存、profile 校准与 Host 启动。
+- 承诺即时撤销前证明关闭设置对在途及暂存更新的实际影响。
+- 覆盖 A 替换为 C、C 失败、期间出现 D、通道切换与安装目标锁定。
+- 保留任务确认、真实进度、自动检查失败静默和恢复到可用已安装应用的行为;不因网络失败阻断正常启动。
+
+## 风险
+
+原生安装器归属限制取消与替换。API 存在本身不能保证启动更快,元数据替换也不是通用回滚机制。库 API 候选、安全默认值、构建引擎与旧客户端兼容性需要按发布版本核验;写入本提案不代表已验证为产品能力。
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml
new file mode 100644
index 0000000000..838cbee511
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
+2026-09-08-desktop-update-policy-and-installation.md: fa88a7dd798e050d658969ad586bd86e0ac323df
+2026-09-08-desktop-update-policy-and-installation.zh.md: d9dc74f66a72dceeafd119fac9e491b3c2a29aa1
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
new file mode 100644
index 0000000000..fa88a7dd79
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md
@@ -0,0 +1,59 @@
+# Agent Note: Desktop update policy and installation
+
+Status: proposed
+
+English | [中文](2026-09-08-desktop-update-policy-and-installation.zh.md)
+
+## Problem
+
+Desktop usually runs a local dsh server, so remote business errors cannot reliably deliver mandatory-update policy. Users need automatic discovery, user-initiated downloads, visible preparation state, and separate restart approval that accounts for running tasks.
+
+## Proposal
+
+This proposal records outstanding release and backend work; the [implemented client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) and [Desktop README](../../../../apps/desktop/README.md) own current behavior.
+
+| Document | Owns |
+|---|---|
+| This proposal | Outstanding release, backend, CDN, and product decisions |
+| [Mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) | Request and response fields, server policy requirements, backend integration checklist |
+| [Deferred extensions](2026-09-08-desktop-update-extensions.md) | Independent Desktop revisions, channel switching, automatic installation, replacement of pending updates |
+
+### Release and backend qualification
+
+Release owners must qualify signed Windows x64 and macOS x64/arm64 upgrades end to end: discovery, download, hash and signature checks, task-safe shutdown, installation, restart, new-version Host startup, and profile reconciliation. Publish immutable packages and blockmaps before the mutable Nightly feed. The [packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns version and artifact rules. The [API proposal](2026-09-08-desktop-mandatory-update-api.md) owns policy fields; production integration must verify guest access, force/no-force responses, errors, platform selection, approved origins, and rate limits. Publish a resolving updater release before enabling mandatory policy.
+
+### Open product choices
+
+Persisting a mandatory block across a same-version offline restart requires product confirmation. If newer C appears while A downloads or waits for installation, retain A until product owners approve replacement; never silently turn approval for A into installation of C. Installed Windows and macOS attention remains subject to notification permissions, focus modes, minimized windows, and stale clicks. The [deferred extensions](2026-09-08-desktop-update-extensions.md) own automatic replacement and installation.
+
+
+
+### CDN and capacity qualification
+
+The following release and operations choices are pending; they are not active CDN settings or completed load qualification.
+
+- [ ] Operations: separate `/dsh-desk/feeds/*` from `/dsh-desk/bin/*`; do not retain a blanket cache bypass for large production downloads. For feeds, evaluate client revalidation with `max-age=0`, a 30–60-second edge TTL, and a purge on publication. Agree on the maximum propagation delay and measure fixed-URL replacement across regions; a purge is not a guarantee of immediate global visibility.
+- [ ] Operations and release owner: evaluate a 30-day to one-year edge TTL for versioned or hash-named packages and blockmaps, without overwriting their URLs. Upload and verify binaries, prewarm them, then publish the feed; confirm retention covers older clients' differential-update inputs.
+- [ ] Operations and Desktop maintainers: verify node TTL separately from client Cache-Control, using repeat-request cache status, hit ratio, and COS origin metrics. Check whether updater-added query parameters fragment the cache key or bypass caching; ignore only parameters proven irrelevant to content. Verify Range/206, Content-Range, complete-file hashes, and feed freshness through the actual updater. See Tencent's [node TTL](https://cloud.tencent.com/document/product/1552/70777), [browser TTL](https://cloud.tencent.com/document/product/1552/70758), and [cache configuration](https://cloud.tencent.com/document/product/1552/95263) documentation.
+- [ ] Desktop maintainers and product owner: confirm startup and overdue-resume burst handling. Periodic jitter and bounded failure backoff are implemented and tested; startup and overdue wakeups still check immediately. Adding a short randomized delay to those triggers needs product confirmation. Mandatory-policy polling remains a separate API and scheduling policy.
+- [ ] Operations and release owner: size request and bandwidth budgets using online clients, startup/manual/retry peaks, package sizes, and expected download participation. At evenly distributed ten-minute polling, 100,000 online clients average about 167 checks/second and 1,000,000 about 1,667, before extra triggers. CDN caching reduces origin load, not client download traffic charges; a functional probe is not a load test.
+- [ ] Operations: configure cache-hit, origin-QPS, error-rate, bandwidth, and cost alerts with agreed thresholds and an on-call owner. Qualify abuse protection without breaking updater requests or shared-NAT clients; updater endpoints must not require an interactive browser challenge. Record an incident response procedure before launch.
+
+## Alternatives considered
+
+**Automatic package predownload.** This consumes bandwidth before the user requests a download. Automatic discovery remains useful, but every initial transfer and retry requires user action independently of later installation approval.
+
+**Automatic restart or installation on ordinary quit.** This bypasses explicit task-impact approval. Automatic installation requires separate product authorization and platform verification.
+
+**One remote API controlling all updates.** Mandatory policy and ordinary artifact discovery have different responsibilities. Keep the policy page separate from updater metadata and reuse only the installation coordinator when qualified.
+
+## Acceptance criteria
+
+- Signed installed-version upgrades pass on Windows x64 and macOS x64/arm64, including new-version Host startup and retained data.
+- Deployed policy integration passes the API proposal’s guest, force/no-force, error, and platform matrix without local fixtures.
+- Release owners record CDN freshness, Range and hash behavior, request and bandwidth budgets, alert thresholds, and an incident owner.
+- Product owners decide offline mandatory persistence and target replacement before either behavior is promised.
+
+## Risks
+
+Local updater and UI checks do not prove installed-upgrade compatibility, deployed policy availability, or CDN performance. Enabling mandatory policy before a resolving artifact exists can block users without an in-app upgrade path.
diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md
new file mode 100644
index 0000000000..d9dc74f66a
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md
@@ -0,0 +1,59 @@
+# Agent Note: Desktop 更新策略与安装
+
+Status: proposed
+
+[English](2026-09-08-desktop-update-policy-and-installation.md) | 中文
+
+## 问题
+
+Desktop 通常运行本地 dsh server,无法依靠远程业务错误可靠触达强制更新策略。用户需要自动发现、主动下载、可见的准备状态,以及考虑运行中任务影响的独立重启确认。
+
+## 提案
+
+本文记录尚未完成的发布与后端工作;[已实施的客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)和[桌面 README](../../../../apps/desktop/README.zh.md)负责当前行为。
+
+| 文档 | 负责内容 |
+|---|---|
+| 本提案 | 未决发布、后端、CDN 与产品决策 |
+| [强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md) | 请求响应字段、服务端策略要求与后端联调清单 |
+| [后续扩展](2026-09-08-desktop-update-extensions.zh.md) | Desktop 独立修订、通道切换、自动安装及待安装更新替换 |
+
+### 发布与后端验收
+
+发布负责人须端到端验收真实签名 Windows x64 与 macOS x64/arm64 升级:发现、下载、哈希与签名检查、任务安全的关闭、安装、重启、新版本 Host 启动及 profile 校准。先发布不可变安装包和 blockmap,再发布可变 Nightly 清单。[打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责版本与产物规则。[接口提案](2026-09-08-desktop-mandatory-update-api.zh.md)负责策略字段;生产联调须验证游客访问、强制/无需强更响应、错误、平台选择、获准源站和速率限制。启用强更策略前先发布能解除要求的 updater 版本。
+
+### 未决产品选择
+
+同版本离线重启后持久保留强更阻断仍需产品确认。若 A 正在下载或等待安装时出现新版 C,在产品负责人批准替换前保留 A;不能把安装 A 的授权静默改为安装 C。已安装 Windows 与 macOS 的提醒仍受通知权限、专注模式、最小化窗口及过期点击影响。[后续扩展](2026-09-08-desktop-update-extensions.zh.md)负责自动替换和安装。
+
+
+
+### CDN 与容量验收
+
+以下发布与运维选择尚待确认,不代表已生效的 CDN 配置或已完成的负载验收。
+
+- [ ] 运维:分别配置 `/dsh-desk/feeds/*` 与 `/dsh-desk/bin/*`,生产大文件下载不应长期沿用全路径绕过缓存。清单建议评估客户端通过 `max-age=0` 重新验证、CDN 节点缓存 30~60 秒、发布时刷新。约定最长传播延迟,并跨区域测量固定 URL 覆盖后的可见时间;刷新不保证全球立即可见。
+- [ ] 运维与发布负责人:对带版本号或哈希的安装包和 blockmap,评估 30 天至一年的节点缓存,不覆盖其 URL。先上传并验证安装包、完成预热,再发布清单;确认保留周期覆盖旧客户端差分更新所需的输入。
+- [ ] 运维与 Desktop 维护者:将节点 TTL 与客户端 Cache-Control 分开验证,检查重复请求的缓存状态、命中率和 COS 回源指标。确认 updater 附加的查询参数是否分散缓存键或绕过缓存;只忽略已证明不影响内容的参数。通过实际 updater 验证 Range/206、Content-Range、完整文件哈希和清单及时更新。参阅腾讯云[节点 TTL](https://cloud.tencent.com/document/product/1552/70777)、[浏览器 TTL](https://cloud.tencent.com/document/product/1552/70758) 与[缓存配置](https://cloud.tencent.com/document/product/1552/95263)文档。
+- [ ] Desktop 维护者与产品负责人:确认启动及到期恢复的突发请求处理。周期抖动和有上限的失败退避已实现并测试;启动和到期唤醒仍立即检查。为这些触发增加短暂随机延迟需要产品确认。强更策略轮询仍使用独立 API 与调度策略。
+- [ ] 运维与发布负责人:依据在线客户端数、启动/手动/重试峰值、安装包大小和预计下载比例制定请求量与带宽预算。按均匀分布的十分钟轮询,10 万在线客户端平均约 167 次检查/秒,100 万约 1667 次,尚未计入额外触发。CDN 缓存减少回源压力,不消除客户端下载流量费用;功能探测不是负载测试。
+- [ ] 运维:配置缓存命中率、回源 QPS、错误率、带宽及费用告警,约定阈值与值班负责人。验证异常请求防护不会破坏 updater 请求或误伤共享 NAT 客户端;updater 端点不能要求交互式浏览器验证。上线前记录故障处置流程。
+
+## 考虑过的替代方案
+
+**自动预下载安装包。** 会在用户请求下载前消耗流量。保留自动发现,但一期每次传输与重试都需要用户操作,与后续安装授权分开。
+
+**自动重启或普通退出时安装。** 会绕过明确的任务影响确认。自动安装需要单独的产品授权与平台验证。
+
+**一个远程接口控制全部更新。** 强制策略与普通产物发现职责不同。策略页面与 updater 元数据分离,验证通过后仅复用安装协调器。
+
+## 验收标准
+
+- 真实签名 Windows x64 与 macOS x64/arm64 安装版升级通过,包括新版本 Host 启动及数据保留。
+- 已部署策略服务通过接口提案的游客、强制/无需强更、错误和平台矩阵,不以本地 fixture 替代。
+- 发布负责人记录 CDN 及时更新、Range 与哈希行为、请求和带宽预算、告警阈值及故障负责人。
+- 产品负责人在承诺离线强更持久化或目标替换前作出决定。
+
+## 风险
+
+本地 updater 与界面检查不能证明已安装升级兼容性、线上策略可用性或 CDN 性能。若在能解除要求的产物可用前启用强更策略,用户可能被阻断且无法在应用内升级。
diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml
new file mode 100644
index 0000000000..35fe5b54d9
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md
+2026-09-17-defer-office-background-scheduling.md: 82311022a015671b9fb4cf3a0f3eba2714f325cd
+2026-09-17-defer-office-background-scheduling.zh.md: ada0146f204fa40e71d48251c231c1b68c5fa5a1
diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md
new file mode 100644
index 0000000000..82311022a0
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md
@@ -0,0 +1,40 @@
+# Agent Note: Defer Office background scheduling until a consumer needs it
+
+Status: proposed
+
+English | [中文](2026-09-17-defer-office-background-scheduling.zh.md)
+
+## Problem
+
+The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits.
+
+The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed.
+
+The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions.
+
+## Proposal
+
+Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit.
+
+Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection.
+
+Update the request/config definitions, package README pair, subsystem and generated catalogs, and conversion fixtures together. Delete priority-only assertions; retain resource and cancellation regressions embedded in mixed priority tests by expressing them with ordinary requests. The existing bounded conversion note remains active because its cache, authorization, and lifetime rationale still applies; acceptance would replace only its scheduling policy.
+
+## Alternatives considered
+
+**Keep priority for a future prewarm consumer.** This avoids a later API change, but retains scheduling states and configuration before a product consumer can define their required behavior. Reintroduce priority with a concrete prewarming caller and measured foreground contention.
+
+**Disable background conversions by default.** This avoids speculative execution but retains the public request class, configuration, and all scheduling branches.
+
+**Remove the whole shared provider or cache.** That exceeds the evidence: independent conversion and bounded sharing remain coherent capabilities, and the Loader smoke exercises them without presentation plugins.
+
+## Acceptance criteria
+
+- Resolve the scheduling needs of delivery prewarming PR #4064 before implementation, then repeat the caller search. Retain priority if that consumer needs foreground protection; do not remove it merely because the consumer has not merged.
+- Priority types, fields, configuration, and scheduling branches disappear from maintained source, tests, documentation, and generated catalogs. Decision records may describe the removed policy.
+- Focused queue/provider tests preserve bounded admission, source/digest sharing, cancellation isolation, output ownership, and delayed capacity release. The real conversion Loader smoke still passes.
+- Verify the foreground Office preview path; update its recorded-session snapshot if affected. Run the relevant type checks, generated-reference checks, documentation checks, and lint.
+
+## Risks
+
+Out-of-tree callers can use the exposed pre-stable API even though repository searches find no background consumer. Removing priority and its config field requires callers to update; this proposal offers no compatibility alias. Speculative callers would lose foreground protection and must stop prewarming until an explicit scheduling requirement is implemented. No runtime behavior changes until this proposal is accepted and implemented.
diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md
new file mode 100644
index 0000000000..ada0146f20
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md
@@ -0,0 +1,40 @@
+# Agent Note: 待调用方需要时再引入 Office 后台调度
+
+Status: proposed
+
+[English](2026-09-17-defer-office-background-scheduling.md) | 中文
+
+## 问题
+
+[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。
+
+[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。
+
+[有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。
+
+## 提案
+
+移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。
+
+保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。
+
+同步更新请求及配置定义、包 README 双语文档、子系统与生成目录,以及转换 fixture(测试前置数据)。删除仅针对优先级的断言;将混合优先级测试中的资源与取消回归改写为普通请求,并保留覆盖。现有有界转换记录继续有效,因为其缓存、授权和生命周期理由仍然成立;采纳本提案只会替换其中的调度策略。
+
+## 考虑过的替代方案
+
+**为未来的预热调用方保留优先级。** 这可以避免后续 API 变更,但在产品调用方能够定义所需行为之前,就保留了调度状态与配置。应在有具体预热调用方和实测前台资源争用时重新引入优先级。
+
+**默认禁用后台转换。** 这可以避免推测性执行,但仍保留公开请求类别、配置及全部调度分支。
+
+**删除整个共享提供者或缓存。** 这超出了证据支持的范围:独立转换和有界共享仍是完整的能力,Loader 冒烟测试也在不依赖展示插件的情况下验证它们。
+
+## 验收标准
+
+- 实施前先明确交付文件预热 PR #4064 的调度需求,再重复调用方搜索。若该消费者需要前台保护,则保留优先级;不能仅因消费者尚未合入而删除。
+- 从维护中的源码、测试、文档和生成目录移除优先级类型、字段、配置及调度分支。决策记录可以描述被移除的策略。
+- 聚焦队列及提供者的测试保留有界接纳、源及摘要共享、取消隔离、输出所有权和延迟容量释放覆盖。真实转换的 Loader 冒烟测试仍然通过。
+- 验证前台 Office 预览路径;若受影响,同步更新其记录会话快照。运行相关类型检查、生成引用检查、文档检查及 lint。
+
+## 风险
+
+仓库外调用方可能使用已暴露的稳定前 API,即使仓库搜索未发现后台消费者。移除优先级及其配置字段要求调用方更新;本提案不提供兼容别名。推测性调用方会失去前台保护,必须停止预热,直到明确的调度需求得到实现。本提案被接受并实现之前,运行行为不变。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.i18n.yaml
new file mode 100644
index 0000000000..c959db81ed
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md
+2026-09-19-config-only-hmr.md: a5f447a8a426e9f2831a1292cae1132acab99023
+2026-09-19-config-only-hmr.zh.md: 24651aa310994b3fec4d925a84fe5d88ae6a5851
diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md
new file mode 100644
index 0000000000..a5f447a8a4
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md
@@ -0,0 +1,36 @@
+# Agent Note: Limit DSH HMR to live profile configuration
+
+Status: proposed
+
+English | [中文](2026-09-19-config-only-hmr.zh.md)
+
+## Problem
+
+[DSH HMR](../../../../packages/boot/hmr/src/index.ts) owns both serialized profile configuration refresh and running JavaScript module replacement. Module replacement adds Node-loader internals, dependency traversal, cache backup, old/new plugin fibers, rollback, module watches, and import-error formatting. The [base composition](../../../../packages/bundle/base/cordis.patch.yml) uses `root: []`; other shipped profiles disable or omit HMR. The default product requires configuration refresh, while source replacement serves an explicit custom-profile opt-in.
+
+That opt-in is real supported behavior: [profile tests](../../../../apps/cli/tests/profile-hmr.spec.ts), module tests, and a [built CLI test](../../../../apps/cli/tests/built-bin.e2e.ts) exercise it, and generated inspection APIs advertise its methods and events. This proposal trades developer continuity for a smaller maintained subsystem; it does not classify the code as unreachable.
+
+## Proposal
+
+Keep `watchConfig`, `runExclusive`, exact profile/home patch and manifest watches, application-readiness coordination, serialized refresh, and disposal drainage. Require a process restart for source-module, arbitrary Include, and framework-dependency changes; remove automatic framework-change calls to `loader.exit()`. Keep browser Client loading independent.
+
+Remove the module watcher and dispatch, dependency graph analysis, module-cache/fiber replacement and restoration, module-only state/options, `baseDir`, `getOuterStack`, `getLinked`, and `hmr/change`/`hmr/reload`. Remove the [import-error formatter](../../../../packages/boot/hmr/src/error.ts), module-only dependencies, and dedicated module/error tests. Keep Chokidar and configuration watcher options actually used by [exact watches](../../../../packages/boot/hmr/src/watch-config.ts). Roughly 400 identified source lines and 372 dedicated test lines belong to the removable module paths; retained configuration code and mixed tests must be counted separately in implementation.
+
+Update the [profile-management decision](../../implemented/architecture/2026-09-14-current-profile-plugin-management.md) and [single-launcher decision](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.md) only where they promise module replacement or its coordination. Their configuration, installation, and launcher responsibilities remain. The [non-transactional Loader decision](../../implemented/simplification/2026-09-09-nontransactional-loader.md) motivates avoiding duplicated rollback but does not already authorize withdrawing source HMR.
+
+## Alternatives considered
+
+**Keep opt-in source replacement.** Plugin authors can edit code without restarting an active Session, and failed replacements restore old plugins. That capability is useful; this proposal deliberately gives it up to remove loader-version coupling and replacement-state ownership from DSH.
+
+**Remove only rollback or delegate replacement elsewhere.** Partial removal can leave cached modules and live fibers from different generations. A wrapper around another HMR implementation retains the public behavior and coordination burden. Neither establishes the complete deletion proposed here.
+
+## Acceptance criteria
+
+- Live profile/home patch and bundle-list changes, Plugin Manager serialization, readiness, self-removal, disposal drainage, and recovery after a later valid edit retain coverage.
+- Source and arbitrary Include changes take effect on restart. Removed source options receive an explicit migration/configuration error; update shipped `root: []` rows rather than silently ignoring obsolete options.
+- Remove module-only APIs, events, dependencies, generated declarations, and tests. Preserve the remaining public queue/watch APIs and package installation/restart semantics.
+- Run focused HMR configuration/watch/coordination and Plugin Manager tests, app-boot reload coverage, and a real built CLI profile-refresh scenario. Replace source-replacement expectations with the agreed restart behavior and update required assembled snapshots and documentation.
+
+## Risks
+
+Custom profiles and installed plugins lose live server-code replacement and its failure restoration. Restarting can interrupt long-lived development work; acceptance therefore requires agreement to this product trade-off. Removing shared configuration coordination or recreating source replacement in another package would defeat the proposal.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md
new file mode 100644
index 0000000000..24651aa310
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: 将 DSH HMR 限定为实时 profile 配置更新
+
+Status: proposed
+
+[English](2026-09-19-config-only-hmr.md) | 中文
+
+## 问题
+
+[DSH HMR](../../../../packages/boot/hmr/src/index.ts) 同时拥有串行 profile 配置刷新和运行中的 JavaScript 模块替换。模块替换增加了 Node loader 内部接口、依赖遍历、缓存备份、新旧插件 fiber、回滚、模块监听与导入错误格式化。[基础组合](../../../../packages/bundle/base/cordis.patch.yml) 使用 `root: []`;其他随产品发布的 profile 禁用或省略 HMR(热模块替换)。默认产品需要配置刷新,而源码替换服务于自定义 profile 的显式启用。
+
+该选项是真实受支持的行为:[profile 测试](../../../../apps/cli/tests/profile-hmr.spec.ts)、模块测试和[构建后 CLI 测试](../../../../apps/cli/tests/built-bin.e2e.ts) 都覆盖它,生成的检查 API 也展示其方法和事件。本提案以开发连续性换取更小的维护子系统,并不将代码判定为不可达。
+
+## 提案
+
+保留 `watchConfig`、`runExclusive`、精确的 profile/home 补丁与 manifest(元数据清单)监听、应用就绪协调、串行刷新和释放排空。源码模块、任意 Include 和框架依赖的修改要求重启进程;删除框架变化时对 `loader.exit()` 的自动调用。浏览器 Client 加载保持独立。
+
+删除模块监听与分发、依赖图分析、模块缓存/fiber 替换与恢复、仅供模块使用的状态/选项、`baseDir`、`getOuterStack`、`getLinked` 及 `hmr/change`/`hmr/reload`。删除[导入错误格式化器](../../../../packages/boot/hmr/src/error.ts)、仅供模块使用的依赖和模块/错误专用测试。保留 Chokidar 及[精确监听](../../../../packages/boot/hmr/src/watch-config.ts) 实际使用的配置监听选项。已定位约 400 行源码和 372 行专用测试属于可删除模块路径;实现时必须单独计算保留的配置代码和混合测试。
+
+仅修改[profile 管理决策](../../implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md) 与[单一启动器决策](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md) 中承诺模块替换及其协调的部分。其配置、安装与启动器职责保留。[非事务式 Loader 决策](../../implemented/simplification/2026-09-09-nontransactional-loader.zh.md) 为避免重复回滚提供理由,但尚未授权撤回源码 HMR。
+
+## 考虑过的替代方案
+
+**保留可选源码替换。** 插件作者可以在不重启活动 Session 的情况下编辑代码,失败替换会恢复旧插件。这项能力有价值;本提案有意放弃它,以移除 DSH 对 loader 版本的耦合及替换状态所有权。
+
+**仅删除回滚或将替换委托到别处。** 部分删除可能让缓存模块和活动 fiber 属于不同代。包装另一个 HMR 实现仍保留公共行为及协调负担。这两种方式都不能实现此处提出的完整删除。
+
+## 验收标准
+
+- 实时 profile/home 补丁与组合包列表变化、Plugin Manager 串行化、就绪、自身移除、释放排空及后续有效编辑后的恢复仍有覆盖。
+- 源码和任意 Include 修改在重启后生效。删除的源码选项给出明确迁移/配置错误;更新随产品发布的 `root: []` 项,不静默忽略过时选项。
+- 删除仅供模块使用的 API、事件、依赖、生成声明与测试。保留剩余公共队列/监听 API,以及包安装/重启语义。
+- 运行聚焦的 HMR 配置/监听/协调及 Plugin Manager 测试、app-boot 重载覆盖与真实构建后 CLI(命令行界面)profile 刷新场景。将源码替换预期改为已同意的重启行为,并更新所需的组装快照与文档。
+
+## 风险
+
+自定义 profile 和已安装插件会失去实时服务端代码替换及其失败恢复。重启可能中断长时间开发工作,因此接受该提案需要同意此产品取舍。删除共享配置协调,或在另一个包重建源码替换,都会违背提案目的。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.i18n.yaml
new file mode 100644
index 0000000000..e582498d41
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md
+2026-09-19-omit-fs-payload-invariant.md: e91fa1784aa99869e3d457f7fc25a330fbc43088
+2026-09-19-omit-fs-payload-invariant.zh.md: ba81baed5f7d7051ef22218d9d59180ac87c8a21
diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md
new file mode 100644
index 0000000000..e91fa1784a
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md
@@ -0,0 +1,34 @@
+# Agent Note: Omit the filesystem payload-only invariant companion
+
+Status: proposed
+
+English | [中文](2026-09-19-omit-fs-payload-invariant.zh.md)
+
+## Problem
+
+The [filesystem invariant](../../../../packages/fs/fs/src/invariant.ts) checks non-empty target strings, a non-empty present version, and an observation discriminant. It reads one dispatch payload, retains no history, and compares no provider, policy, filesystem, or independently mutable state. Its [tests](../../../../packages/fs/fs/tests/invariant.spec.ts) construct malformed payloads directly. Publishing this check costs a companion module, export/build wiring, invariant dependencies, compiler references, and dedicated tests.
+
+Current guidance needs an explicit decision: the [independent-observation rule](../../implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires observations that can independently diverge, while the [runtime-contract examples](../../implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md) specifically endorse filesystem target/version validity. The later rule retains the earlier note's semantic authority, so chronology alone does not resolve this conflict.
+
+## Proposal
+
+Apply the independent-observation criterion to this concrete companion and remove it. The event producers are independent plugins, but checking the format of each single payload does not compare their observations. Keep typed filesystem events and the real [observation-policy consumer](../../../../packages/fs/fs-observation-policy/src/index.ts), along with parser, wire, read, edit, and write validation at their existing owners.
+
+Remove the 48-line companion, its 55-line dedicated test, `./invariant` export/publication, invariant-only dependencies and compiler references. Add the package-specific omission reason to both fs READMEs. Amend the earlier representative-check row when implementing this decision, retaining its other semantic examples and the shared invariant registration machinery. This proposal leaves those current owners unchanged until acceptance.
+
+## Alternatives considered
+
+**Keep payload validation as an explicit policy exception.** It can detect malformed third-party emissions early, but this would deliberately broaden the stated independent-observation criterion. The proposal prefers input validation at its owner and reserves companions for relational checks.
+
+**Add a second state source to justify the companion.** Rejected without a concrete missing invariant. Duplicating policy state or exposing new API solely to retain this diagnostic increases the maintenance burden.
+
+## Acceptance criteria
+
+- Resolve the named policy conflict in the active owning notes; do not claim this is already an uncontested mechanical omission.
+- No companion source, test, export, artifact entry, invariant-only dependency, or compiler reference remains; package topology checks accept the explained omission.
+- Real file-tool and observation-policy tests retain stale-version, absent-target, alias, actor-routing, and world-state assertions. The events and consumers remain.
+- Run focused fs and observation-policy tests, relevant file-tool integration checks, source/built invariant-topology checks, build/hygiene, doc-sync, and lint. Do not replace the module with equivalent generic payload-validation machinery.
+
+## Risks
+
+Malformed trusted-plugin emissions lose this opt-in early diagnostic. The check's removal does not justify weakening validation of external data, nor removing other companions that compare lifecycle, durable history, or independently assembled values. If payload-format diagnostics are intentionally part of the companion policy, retain this module and clarify that exception instead.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md
new file mode 100644
index 0000000000..ba81baed5f
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md
@@ -0,0 +1,34 @@
+# Agent Note: 省略仅校验载荷的文件系统 invariant 配套模块
+
+Status: proposed
+
+[English](2026-09-19-omit-fs-payload-invariant.md) | 中文
+
+## 问题
+
+[文件系统 invariant](../../../../packages/fs/fs/src/invariant.ts) 检查目标字符串非空、present 版本非空及观察值的判别标签。它只读取一次分发的载荷,不保留历史,也不与提供方、策略、文件系统或独立可变状态比较。其[测试](../../../../packages/fs/fs/tests/invariant.spec.ts) 直接构造格式错误的载荷。发布该检查需要配套模块、导出/构建配置、invariant 依赖、编译器引用和专用测试。
+
+当前指导需要显式决策:[独立观察规则](../../implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md) 要求能够独立分歧的观察值,而[运行时约定示例](../../implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md) 明确认可文件系统目标/版本有效性检查。后者的语义权威仍被较新的规则保留,因此不能仅凭时间先后解决冲突。
+
+## 提案
+
+将独立观察标准应用于这个具体配套模块,并删除它。事件生产方来自独立插件,但逐一检查单个载荷的格式,并没有比较它们的观察值。保留类型化文件系统事件和实际的[观察策略消费方](../../../../packages/fs/fs-observation-policy/src/index.ts),以及现有归属处的解析器、wire、读取、编辑和写入校验。
+
+删除 48 行配套模块、55 行专用测试、`./invariant` 导出/发布项、仅供 invariant 使用的依赖和编译器引用。在 fs 的两个 README 中补充包特有的省略理由。实现本决策时修改较早的代表性检查行,保留其他语义示例及共享 invariant 注册机制。本提案获接受前不修改这些当前归属文档。
+
+## 考虑过的替代方案
+
+**将载荷校验保留为显式策略例外。** 它能提前发现第三方格式错误的事件,但这会有意扩大已声明的独立观察标准。提案倾向于在输入归属处校验,并将配套模块留给关系检查。
+
+**添加第二个状态源以证明配套模块有必要。** 在没有具体缺失 invariant 的情况下否决。仅为保留此诊断而复制策略状态或暴露新 API,会增加维护负担。
+
+## 验收标准
+
+- 在当前归属 Agent Notes 中解决已指出的策略冲突;不将其宣称为已有共识的机械省略。
+- 不再保留配套源码、测试、导出、产物条目、仅供 invariant 使用的依赖或编译器引用;包拓扑检查接受有说明的省略。
+- 真实文件工具和观察策略测试保留过期版本、目标缺失、别名、执行方路由及外部状态断言。事件及其消费方保留。
+- 运行聚焦的 fs 与观察策略测试、相关文件工具集成检查、源码/构建产物 invariant 拓扑检查、build/hygiene、doc-sync(文档同步门禁)和 lint。不用等价的通用载荷校验机制替换该模块。
+
+## 风险
+
+来自受信任插件的格式错误事件会失去这项可选的早期诊断。删除该检查不构成削弱外部数据校验的理由,也不意味着可以删除比较生命周期、持久化历史或独立组装值的其他配套模块。如果载荷格式诊断本来就是配套模块策略的一部分,应保留模块并澄清该例外。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.i18n.yaml
new file mode 100644
index 0000000000..46510688ef
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md
+2026-09-19-provider-only-instruction-reads.md: 26d004c136cff8deb155007cf8d7743bc8bba95a
+2026-09-19-provider-only-instruction-reads.zh.md: 82f3451f96578f1ee596bf9291a1a3c935d5f698
diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md
new file mode 100644
index 0000000000..26d004c136
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md
@@ -0,0 +1,37 @@
+# Agent Note: Read instruction files only through a filesystem provider
+
+Status: proposed
+
+English | [中文](2026-09-19-provider-only-instruction-reads.zh.md)
+
+## Problem
+
+[Instruction discovery](../../../../packages/context/agent-instructions/src/files.ts) maintains provider and direct-Node paths for metadata probes, root-marker discovery, and bounded reads. `nodeStatFile`, `nodeTextChunks`, and optional provider/target fields support the latter. The exported `discoverBaselineInstructionFiles` wrapper selects it; `loadBaselineInstructions` permits it when callers omit a provider.
+
+Every repository production caller supplies a provider. The [plugin](../../../../packages/context/agent-instructions/src/index.ts) reads `ctx.get('fs')` and returns when absent; [reconciliation](../../../../packages/context/agent-instructions/src/state.ts) requires `FileSystem`. Direct-Node consumers are tests. Maintaining both paths therefore adds separate error and cancellation coverage without serving a shipped product path.
+
+## Proposal
+
+Require `FileSystem` throughout discovery and loading, including both exported wrappers and `findProjectRoot`. Remove `nodeStatFile`, `nodeTextChunks`, Node-only missing-path classification, the Node branch of `existsAsMarker`, and the `statFile` selector. Provider discovery supplies required targets; bounded reads consume those targets through `streamText`.
+
+Keep the plugin's missing-provider no-op and the pure `renderAgentInstructions` API. Preserve candidate precedence, source and rendered-output byte budgets, cancellation, symlink following, and error semantics: unavailable candidates cannot imply removal, while root-marker failures remain errors. The separate `skill-filesystem` owner retains its existing providerless behavior.
+
+Migrate direct helper tests to explicitly owned local or controlled providers. The [test suite](../../../../packages/context/agent-instructions/tests/agent-instructions.spec.ts) already mounts both. Preserve unique assertions and consolidate duplicate Node-mocking cases into provider coverage. Expected deletion is roughly 50–65 production lines before signature/documentation updates, plus redundant test machinery; measure the actual reduction during implementation.
+
+The [symlink decision](../../implemented/feature/2026-07-21-follow-instruction-symlinks.md) remains active because its accepted behavior survives. At implementation, update its references to the two implementations and the [package README](../../../../packages/context/agent-instructions/README.md), including bilingual counterparts. The [archived workspace-context note](../../archived/feature/2026-06-24-workspace-context.md) remains frozen; no existing note changes accompany this proposal.
+
+## Alternatives considered
+
+**Keep providerless convenience exports.** They let external callers load instructions without constructing a provider, but require a second I/O implementation that production bypasses. This proposal deliberately gives up that convenience; repository searches do not establish external non-use.
+
+**Construct a local provider implicitly.** This preserves the omitted argument while hiding filesystem selection and lifecycle ownership. Callers should supply the implementation they intend to use.
+
+## Acceptance criteria
+
+- This owner's discovery/read helpers require a provider and contain no direct-Node I/O fallback; providerless plugin composition still loads nothing.
+- Focused tests preserve symlink, absence/unavailability, marker-error, budget, cancellation, resume, nested-touch, and compaction-restoration behavior.
+- The workspace-context resume process expectation and `ptc-workspace-context` scenario retain their text and event order. Typecheck, affected builds, and documentation checks pass.
+
+## Risks
+
+External callers omitting the provider face an intentional pre-stable API change. Identify such requirements before implementation. Keep filesystem extension paths intact, and do not replace useful provider tests with a new convenience wrapper that recreates the removed fallback.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md
new file mode 100644
index 0000000000..82f3451f96
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 仅通过文件系统提供方读取指令文件
+
+Status: proposed
+
+[English](2026-09-19-provider-only-instruction-reads.md) | 中文
+
+## 问题
+
+[指令发现](../../../../packages/context/agent-instructions/src/files.ts)为元数据探测、根标记发现和有界读取维护了提供方与直接 Node 两条路径。`nodeStatFile`、`nodeTextChunks` 和可选的提供方/目标字段支持后者。导出的 `discoverBaselineInstructionFiles` 包装函数选择该路径;`loadBaselineInstructions` 则允许调用方通过省略提供方来使用它。
+
+仓库中的每个生产调用方都传入提供方。[插件](../../../../packages/context/agent-instructions/src/index.ts)读取 `ctx.get('fs')`,缺失时直接返回;[协调逻辑](../../../../packages/context/agent-instructions/src/state.ts)要求传入 `FileSystem`。直接 Node 路径的消费方都是测试。因此,维护两条路径增加了独立的错误与取消覆盖,却没有服务于已交付的产品路径。
+
+## 提案
+
+在整个发现与加载链路中要求传入 `FileSystem`,包括两个导出的包装函数和 `findProjectRoot`。移除 `nodeStatFile`、`nodeTextChunks`、仅供 Node 使用的路径缺失分类、`existsAsMarker` 的 Node 分支以及 `statFile` 选择器。提供方发现过程给出必需的目标;有界读取通过 `streamText` 消费这些目标。
+
+保留插件缺少提供方时不执行操作的行为,以及纯函数 `renderAgentInstructions` API。保留候选优先级、源文件与渲染输出的字节预算、取消、符号链接跟随和错误语义:候选不可用不能表示应移除已有内容,根标记失败仍是错误。独立的 `skill-filesystem` 所有者保留其现有的无提供方行为。
+
+让直接调用辅助函数的测试改用显式管理生命周期的本地或受控提供方。[测试套件](../../../../packages/context/agent-instructions/tests/agent-instructions.spec.ts)已经挂载了这两类实现。保留独有断言,把重复的 Node mock 场景合并到提供方覆盖中。预计在更新签名和文档之前删除约 50–65 行生产源码,另加冗余测试机制;实施时测量实际减少量。
+
+[符号链接决策](../../implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md)所接受的行为继续存在,因此该说明保持现行状态。实施时,更新其中对两种实现的引用和[包 README](../../../../packages/context/agent-instructions/README.zh.md),包括双语对侧。[已归档的工作区上下文说明](../../archived/feature/2026-06-24-workspace-context.md)保持冻结;本提案不附带任何既有说明变更。
+
+## 备选方案
+
+**保留无提供方的便利导出。** 它们让外部调用方无需构造提供方便可加载指令,但也要求保留生产路径不使用的第二套 I/O 实现。本提案有意放弃这种便利;仓库搜索不能确定外部无人使用。
+
+**隐式构造本地提供方。** 这会保留省略参数的调用方式,却隐藏文件系统选择和生命周期归属。调用方应传入它们打算使用的实现。
+
+## 验收标准
+
+- 本所有者的发现/读取辅助函数要求提供方,且不含直接 Node I/O 回退;无提供方的插件组合仍不加载任何内容。
+- 针对性测试保留符号链接、缺失/不可用、标记错误、预算、取消、恢复、嵌套访问和压缩(compaction)后恢复的行为。
+- 工作区上下文恢复的进程预期输出和 `ptc-workspace-context` 场景保持原有文本及事件顺序。类型检查、受影响构建和文档检查通过。
+
+## 风险
+
+省略提供方的外部调用方面临有意的稳定前 API 变更。实施前确认这类需求。保留文件系统扩展路径,也不要用重新创建已移除回退的新便利包装函数替换有用的提供方测试。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.i18n.yaml
new file mode 100644
index 0000000000..6360d57daa
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md
+2026-09-19-prune-stream-visibility-helpers.md: e44716fe15eb0ff09020ff4417b1dd9f4e9c29f4
+2026-09-19-prune-stream-visibility-helpers.zh.md: 60d010f260a6cadc99a15bde164bb0717dd7502a
diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md
new file mode 100644
index 0000000000..e44716fe15
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md
@@ -0,0 +1,35 @@
+# Agent Note: Prune unused stream visibility helpers
+
+Status: proposed
+
+English | [中文](2026-09-19-prune-stream-visibility-helpers.zh.md)
+
+## Problem
+
+The [compact Assistant stream implementation](../../../../packages/llm/llm/src/assistant-stream.ts) exports five visibility queries: `isVisibleChunk`, `chunkHasVisibleText`, `runFirstVisibleTime`, `assistantStreamHasVisibleContent`, and `assistantStreamHasVisibleText`. Repository searches find no production caller outside that family. Its other consumers are the [unit tests](../../../../packages/llm/llm/tests/assistant-stream.spec.ts), package documentation, and the [record-reader decision](../../implemented/architecture/2026-09-06-embedded-stream-record-readers.md). The package root and published `./assistant-stream` entry expose the functions to external callers, whose usage this search cannot establish.
+
+The retained first-token reader has current consumers in [Session Stats](../../../../packages/session/session-stats/src/projection.ts) and [Trajectory](../../../../packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts). Their timing requirements do not require the separate whitespace-sensitive visibility classification.
+
+## Proposal
+
+Remove the five exported visibility queries and their exclusive private helpers, `hasNonWhitespace` and `blockIsVisible`. This deletes approximately 77 source lines, including local documentation, without adding replacement machinery. Retain all first-token, raw-chunk, text-joining, and assembly readers, the accumulator, and validating expansion. Preserve the direct and library-backed LLM adapters and their extension APIs.
+
+Remove dedicated visibility tests and only the obsolete assertions inside mixed reader tests. Keep the mixed fixtures and assertions that verify first-token timing, early exit, raw chunks, text joining, and assembly equivalence.
+
+The record-reader decision is only partially superseded: its allocation rationale and used readers remain current. When implementing this proposal, update that active note, the [package README](../../../../packages/llm/llm/README.md), and the [LLM subsystem page](../../../../docs/subsystems/llm-streaming.md) with their bilingual counterparts. Do not delete the owning note or alter archived records while adding this proposal.
+
+## Alternatives considered
+
+**Keep a complete reader family for external consumers.** This preserves convenient, tested visibility queries, but keeps public functions and dedicated tests without a demonstrated repository consumer. A named external requirement would justify retaining or reintroducing them.
+
+**Replace visibility queries with stream expansion.** There is no production caller to migrate. Adding replacements would retain an unused API and introduce the allocations the record readers avoid.
+
+## Acceptance criteria
+
+- The five names disappear from source exports and current API documentation; no production caller gains an expansion or replacement visibility query.
+- Focused stream-reader, Session Stats, and Trajectory timing checks pass; mixed tests retain their unrelated assertions.
+- Typecheck, affected published-entry builds, and documentation checks pass. Session formats, model input, transcript content, and first-token timing remain unchanged.
+
+## Risks
+
+This deliberately contracts exported pre-stable APIs. An external import may break even though repository consumers are absent. Refresh consumer evidence before implementation and assess any identified external requirement explicitly; do not claim ecosystem-wide non-use.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md
new file mode 100644
index 0000000000..60d010f260
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 精简未使用的流可见性辅助函数
+
+Status: proposed
+
+[English](2026-09-19-prune-stream-visibility-helpers.md) | 中文
+
+## 问题
+
+[紧凑 Assistant 流实现](../../../../packages/llm/llm/src/assistant-stream.ts)导出五个可见性查询:`isVisibleChunk`、`chunkHasVisibleText`、`runFirstVisibleTime`、`assistantStreamHasVisibleContent` 和 `assistantStreamHasVisibleText`。仓库搜索未找到这组函数之外的生产调用方。其余消费方是[单元测试](../../../../packages/llm/llm/tests/assistant-stream.spec.ts)、包文档和[记录读取器决策](../../implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md)。包根入口和发布的 `./assistant-stream` 入口向外部调用方暴露这些函数;本次搜索无法确定外部使用情况。
+
+保留的首 token 读取器已有 [Session Stats](../../../../packages/session/session-stats/src/projection.ts) 和 [Trajectory](../../../../packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts) 消费方。它们的计时要求不依赖单独区分空白文本的可见性分类。
+
+## 提案
+
+移除这五个导出的可见性查询及其专用私有辅助函数 `hasNonWhitespace` 和 `blockIsVisible`。这会删除约 77 行源码(含局部文档),无需新增替代机制。保留所有首 token、原始分片、文本拼接和组装读取器,以及累加器和带校验的展开操作。保留直接实现和基于库实现的两个 LLM(大语言模型)适配器及其扩展 API。
+
+移除专门的可见性测试;混合读取器测试只删除过时断言。保留验证首 token 计时、提前退出、原始分片、文本拼接和组装等价性的混合 fixture(测试前置数据)与断言。
+
+记录读取器决策只被部分替代:其分配开销方面的理由和仍被使用的读取器继续有效。实施本提案时,更新该现行说明、[包 README](../../../../packages/llm/llm/README.zh.md) 和 [LLM 子系统页面](../../../../docs/subsystems/llm-streaming.zh.md)及其双语对侧。添加本提案时不删除所属说明,也不修改归档记录。
+
+## 备选方案
+
+**为外部消费方保留完整的读取器组。** 这能保留方便且经过测试的可见性查询,但也会在缺少已确认仓库消费方的情况下保留公共函数和专门测试。明确的外部需求可以成为保留或重新引入它们的理由。
+
+**用流展开替代可见性查询。** 当前没有需要迁移的生产调用方。新增替代实现会继续保留未使用的 API,并引入记录读取器原本避免的分配开销。
+
+## 验收标准
+
+- 源码导出和现行 API 文档中不再包含这五个名称;生产调用方不新增展开操作或替代可见性查询。
+- 针对流读取器、Session Stats 和 Trajectory 计时的检查通过;混合测试保留无关断言。
+- 类型检查、受影响发布入口的构建和文档检查通过。Session 格式、模型输入、transcript(文本记录)内容和首 token 计时保持不变。
+
+## 风险
+
+这会有意收缩导出的稳定前 API。即使仓库中没有消费方,外部导入仍可能失效。实施前重新核对消费方证据,并明确评估发现的外部需求;不得宣称整个生态均未使用这些函数。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.i18n.yaml
new file mode 100644
index 0000000000..3920961bcf
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md
+2026-09-19-python-sdk-directional-client.md: 287126d65cc9e81e240c2b18081b2935c107760a
+2026-09-19-python-sdk-directional-client.zh.md: caf9b7c73a00b8865b6d189fe512f96afb20dad9
diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md
new file mode 100644
index 0000000000..287126d65c
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md
@@ -0,0 +1,34 @@
+# Agent Note: Remove the Python SDK's unused reverse RPC direction
+
+Status: proposed
+
+English | [中文](2026-09-19-python-sdk-directional-client.zh.md)
+
+## Problem
+
+The [Python client](../../../../python/sdk/src/deepseek_harness/client.py) keeps an incoming-request queue, close-time wake-up, and `next_request`, `respond`, `respond_error`, and `notify` methods. The [SDK server](../../../../packages/sdk/server/src/server.ts) receives requests and emits notifications; it declares no server-originated request or client-notification handler. Searches across Python sources, examples, and runtime profiles found the reverse direction only in client implementation and [synthetic client tests](../../../../python/sdk/tests/test_client.py). The exported `IncomingRequest` model consequently has no supported runtime producer.
+
+This is a narrower continuation of the [directional JSON-RPC proposal](../../rejected/simplification/2026-07-19-make-jsonrpc-directional.md), whose rejection explicitly permits transport narrowing separately. Its completion redesign remains rejected. Its claim about the shared TypeScript transport is now inapplicable: the [Codex wire adapter](../../../../packages/subagent/subagent-codex/src/wire.ts) uses requests and notifications in both directions.
+
+## Proposal
+
+Remove the four Python methods, `_requests`, and `IncomingRequest` from the client, models, and exports. Retain an explicit guard for an unexpected frame containing both `id` and `method` before response matching, so a server request cannot settle an unrelated pending client request. Keep client requests, response validation, incoming notification subscriptions, and shutdown unchanged.
+
+Remove the dedicated synthetic reverse-request test. Change tests that send client notifications only to trigger fixture behavior to use a request and response instead; retain their filter-exception and concurrent-write assertions. This removes one queue and its lifetime, four public methods, one public model, and roughly three dozen source lines without adding a replacement transport.
+
+## Alternatives considered
+
+**Keep a generic Python peer for external plugins.** Custom runtimes could use the methods, but no SDK method describes their request ownership, cancellation, or delivery protocol. The proposal accepts this pre-stable API loss; a future bidirectional SDK feature must supply its actual method and lifecycle requirements.
+
+**Narrow the shared TypeScript transport too.** Rejected because the Codex adapter is a current bidirectional consumer. The shared implementation and its tests remain.
+
+## Acceptance criteria
+
+- Python sources and examples have no reverse-direction API or request queue; documentation and exports describe the retained client role.
+- An unexpected server request reusing a pending client id cannot complete its waiter. Notification filtering, sibling delivery, final draining, malformed-frame handling, serialized writes, and shutdown still pass their existing tests.
+- Prompt submission still returns the durable enqueue receipt; no per-prompt completion promise or response-based turn result is introduced.
+- Run the Python client suite and installed-runtime SDK smoke and snapshot checks, plus documentation and lint checks. Preserve the TypeScript transport and released Session fixtures.
+
+## Risks
+
+External Python callers using custom runtime plugins lose these methods. Ignoring an unsupported request does not fulfill a hypothetical server's response expectation; this is deliberate withdrawal of that direction, not protocol parity. Revisit the proposal if a supported server-originated method lands before implementation.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md
new file mode 100644
index 0000000000..caf9b7c73a
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md
@@ -0,0 +1,34 @@
+# Agent Note: 删除 Python SDK 未使用的反向 RPC
+
+Status: proposed
+
+[English](2026-09-19-python-sdk-directional-client.md) | 中文
+
+## 问题
+
+[Python 客户端](../../../../python/sdk/src/deepseek_harness/client.py) 维护入站请求队列、关闭时的唤醒,以及 `next_request`、`respond`、`respond_error` 和 `notify` 方法。[SDK 服务端](../../../../packages/sdk/server/src/server.ts) 接收请求并发送通知,没有定义服务端主动发起的请求或客户端通知处理器。搜索 Python 源码、示例和运行时 profile 后,仅在客户端实现和[合成客户端测试](../../../../python/sdk/tests/test_client.py) 中发现反向通信。因此,导出的 `IncomingRequest` 模型没有受支持运行时的生产方。
+
+这是[定向 JSON-RPC 提案](../../rejected/simplification/2026-07-19-make-jsonrpc-directional.zh.md) 的缩小范围续案;该提案的否决原因明确允许单独重提传输收缩。其完成语义重设计仍被否决。它对共享 TypeScript 传输的判断已不适用:[Codex wire 适配器](../../../../packages/subagent/subagent-codex/src/wire.ts) 在两个方向上都使用请求和通知。
+
+## 提案
+
+从客户端、模型和导出中删除四个 Python 方法、`_requests` 和 `IncomingRequest`。在匹配响应前,保留对同时含有 `id` 和 `method` 的意外帧的显式保护,避免服务端请求完成无关的待处理客户端请求。保持客户端请求、响应校验、入站通知订阅和关闭行为。
+
+删除专门的合成反向请求测试。仅为触发 fixture(测试前置数据)行为而发送客户端通知的测试改用请求和响应,同时保留过滤器异常与并发写入断言。该变更删除一个队列及其生命周期、四个公共方法、一个公共模型和约三十多行源码,不增加替代传输实现。
+
+## 考虑过的替代方案
+
+**保留通用 Python 对等端供外部插件使用。** 自定义运行时可能使用这些方法,但没有 SDK 方法规定其请求所有权、取消或交付协议。提案接受这一稳定前 API 损失;未来双向 SDK 功能必须提供实际的方法与生命周期要求。
+
+**同时收缩共享 TypeScript 传输。** 否决,因为 Codex 适配器是当前双向消费方。共享实现及其测试保留。
+
+## 验收标准
+
+- Python 源码和示例不再包含反向 API 或请求队列;文档和导出描述保留的客户端角色。
+- 复用待处理客户端 id 的意外服务端请求不能完成其等待方。通知过滤、同级交付、末尾排空、错误帧处理、串行写入和关闭仍通过现有测试。
+- 提交提示仍返回持久化入队回执;不引入单条提示的完成承诺或基于响应的轮次结果。
+- 运行 Python 客户端测试、已安装运行时的 SDK 冒烟与快照检查,以及文档和 lint 检查。保留 TypeScript 传输和已发布 Session fixture。
+
+## 风险
+
+使用自定义运行时插件的外部 Python 调用方会失去这些方法。忽略不支持的请求无法满足假想服务端的响应期待;这是有意撤回该方向,而非保持协议等价。如果受支持的服务端主动请求方法先于实现落地,需要重新评估提案。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.i18n.yaml
new file mode 100644
index 0000000000..d11c6bed98
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md
+2026-09-19-retire-app-bin-boot-helpers.md: c67013bfae8569d0f750b3c8be5e56c57a2a66aa
+2026-09-19-retire-app-bin-boot-helpers.zh.md: 5ef68ff3d8666e8b74d36c50eacbcf5048a4438a
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md
new file mode 100644
index 0000000000..c67013bfae
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md
@@ -0,0 +1,36 @@
+# Agent Note: Retire app-boot helpers used only by fixtures
+
+Status: proposed
+
+English | [中文](2026-09-19-retire-app-bin-boot-helpers.zh.md)
+
+## Problem
+
+[App boot](../../../../packages/boot/app-boot/src/index.ts) exports `resolveConfigPath`, including a basename-specific replay substitution, and `loadEnv`, which reads one optional `.env`. The supported [CLI launcher](../../../../apps/cli/src/bin.ts) uses `loadLayeredEnv`; [profile boot](../../../../apps/cli/src/profile-boot.ts) supplies an absolute path directly. Neither production path calls the older helpers.
+
+An exact-symbol search across packages, apps, and scripts finds ten `resolveConfigPath` calls in eight fixture drivers, all passing `undefined` for replay mode. The only non-unit `loadEnv` caller is the [Loader smoke fixture](../../../../packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts). The [snapshot launcher](../../../../packages/test-support/session-snapshot/src/launcher.ts) separately owns replay patch selection. The old helpers consequently retain two public APIs, unused production replay policy, and dedicated tests for fixture-only needs.
+
+The [shared app-bin glue record](../../archived/simplification/2026-07-04-share-app-bin-boot-glue.md) explains the original shared-bin use. The active [single-launcher decision](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.md) replaces those supported entry paths. This is partial supersession: `boot` and fail-loud handling still have owners, and the archived record remains frozen.
+
+## Proposal
+
+Delete `resolveConfigPath` and `loadEnv` from the production export. Use Node's `path.resolve` in the eight fixture drivers. Keep optional one-directory `.env` handling local to the sole fixture that needs it, including its absent-file and warning behavior; do not introduce a replacement production package.
+
+Remove helper-specific tests and update app-boot's package description, README pair, and `boot` JSDoc. Keep `boot`, `loadLayeredEnv`, shipped profile composition, and snapshot replay selection. The identified removal is 39 production source/JSDoc lines and about 73 dedicated test lines, less the small fixture-local environment handling and builtin imports; the estimate is not an implemented diff.
+
+## Alternatives considered
+
+**Keep public conveniences for low-level embedders.** They can be useful, but current fixtures request only path resolution and one optional file read. Maintaining a second production replay selector and obsolete app-bin guidance is disproportionate to those uses.
+
+**Replace every use with `loadLayeredEnv`.** Rejected because layered discovery, filtering, and precedence differ from reading one fixture-local `.env`. Sharing a name does not establish equivalent behavior.
+
+## Acceptance criteria
+
+- Exact-name searches find no live helper imports or calls. All ten path replacements remain absolute and select the same files.
+- The sole environment-loading fixture preserves missing-file, present-file, and warning behavior; helper-only tests disappear without dropping retained boot coverage.
+- Run focused app-boot and affected fixture-entry tests, including built entry checks where required, a shipped profile smoke, and a keyless replay scenario.
+- Update README pairs and package/JSDoc descriptions; run build, relevant hygiene, typecheck, doc-sync, and lint. Add no compatibility aliases or second replay selector.
+
+## Risks
+
+Out-of-tree callers importing these pre-stable helpers must use the retained profile APIs or own their low-level path/environment policy. Accidentally switching the fixture to layered environment discovery would change its test inputs; preserve that distinction explicitly.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md
new file mode 100644
index 0000000000..5ef68ff3d8
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: 删除仅供 fixture 使用的 app-boot 辅助函数
+
+Status: proposed
+
+[English](2026-09-19-retire-app-bin-boot-helpers.md) | 中文
+
+## 问题
+
+[App boot](../../../../packages/boot/app-boot/src/index.ts) 导出 `resolveConfigPath`,其中包含按文件名替换重放配置的逻辑,以及读取单个可选 `.env` 的 `loadEnv`。受支持的 [CLI 启动器](../../../../apps/cli/src/bin.ts) 使用 `loadLayeredEnv`;[profile boot](../../../../apps/cli/src/profile-boot.ts) 直接提供绝对路径。这两条生产路径都不调用旧辅助函数。
+
+在 packages、apps 和 scripts 中搜索精确符号,可找到八个 fixture(测试前置数据)驱动器中的十次 `resolveConfigPath` 调用,重放模式全部传入 `undefined`。唯一非单元测试的 `loadEnv` 调用方是 [Loader 冒烟 fixture](../../../../packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts)。[快照启动器](../../../../packages/test-support/session-snapshot/src/launcher.ts) 单独拥有重放补丁选择。因此,旧辅助函数仅为 fixture 的需要保留了两个公共 API、生产中未使用的重放策略及专用测试。
+
+[共享 app-bin 胶水代码记录](../../archived/simplification/2026-07-04-share-app-bin-boot-glue.md) 解释了最初的共享可执行入口用途。当前[单一启动器决策](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md) 已替代这些受支持入口。这属于部分取代:`boot` 和明确失败处理仍有归属,归档记录保持冻结。
+
+## 提案
+
+从生产导出中删除 `resolveConfigPath` 和 `loadEnv`。八个 fixture 驱动器改用 Node 的 `path.resolve`。仅在需要它的 fixture 内保留读取单目录可选 `.env` 的处理,包括文件缺失与警告行为;不引入替代生产包。
+
+删除辅助函数专用测试,并更新 app-boot 的包描述、README 配对及 `boot` JSDoc。保留 `boot`、`loadLayeredEnv`、随产品发布的 profile 组合和快照重放选择。已定位可删除 39 行生产源码/JSDoc 和约 73 行专用测试,需扣除少量 fixture 本地环境处理及内置模块导入;这是估算,尚非实现 diff。
+
+## 考虑过的替代方案
+
+**为底层嵌入方保留公共便利函数。** 它们可能有用,但当前 fixture 仅需要路径解析和单个可选文件读取。为这些用途维护第二套生产重放选择器及过时的 app-bin 指南,成本过高。
+
+**将所有调用替换为 `loadLayeredEnv`。** 否决,因为分层发现、过滤和优先级不同于读取单个 fixture 本地 `.env`。名称相近不能证明行为等价。
+
+## 验收标准
+
+- 精确名称搜索不再发现有效的辅助函数导入或调用。十处路径替换仍为绝对路径并选择相同文件。
+- 唯一加载环境的 fixture 保持文件缺失、文件存在和警告行为;删除辅助函数专用测试时不丢失保留的 boot 覆盖。
+- 运行聚焦的 app-boot 和受影响 fixture 入口测试,包括所需的构建产物入口检查、随产品发布的 profile 冒烟,以及无密钥重放场景。
+- 更新 README 配对及包/JSDoc 描述;运行 build、相关 hygiene、typecheck、doc-sync(文档同步门禁)和 lint。不添加兼容别名或第二个重放选择器。
+
+## 风险
+
+导入这些稳定前辅助函数的仓库外调用方,需要改用保留的 profile API,或自行拥有底层路径与环境策略。意外将 fixture 改为分层环境发现会改变其测试输入;必须明确保留这一差别。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.i18n.yaml
new file mode 100644
index 0000000000..625e054ecf
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md
+2026-09-19-retire-prompt-registry-change-event.md: 1103675525e30c7f6c345a1fe92f07cbd0bcce4a
+2026-09-19-retire-prompt-registry-change-event.zh.md: a49df2eebad7134b7b038ee2e6ab54610bd4c635
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md
new file mode 100644
index 0000000000..1103675525
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md
@@ -0,0 +1,35 @@
+# Agent Note: Retire the prompt registry change event
+
+Status: proposed
+
+English | [中文](2026-09-19-retire-prompt-registry-change-event.zh.md)
+
+## Problem
+
+[`SystemPrompt`](../../../../packages/core/system-prompt/src/index.ts) emits `system-prompt/change` when providers register or dispose. Repository searches find no executing product listener, but generated Host discovery advertises the event. Tests maintain notification counts and rollback after listener exceptions. This is a public extension with maintenance obligations, even without a first-party subscriber.
+
+The active [Remote event delivery decision](../../implemented/architecture/2026-08-10-remote-event-delivery.md) explicitly retains this extension despite having no shipped consumer. Per-step assembly predates that promise and serves a different purpose: rebuilding model input does not replace push observation of registry changes.
+
+## Proposal
+
+Retire `system-prompt/change` and accept the loss of prompt-registry push observation for installed and dynamically authored Host plugins. Remove its declaration, emitter, current documentation, generated discovery entries, and notification-specific tests. No currently supported product path requires this extension.
+
+Make [`ScopedLayers`](../../../../packages/core/scope/src/store.ts)'s constructor notification callback optional and omit it for `SystemPrompt`; do not supply a no-op callback. Remove the existing no-op arguments in [jobs-local](../../../../packages/jobs/jobs-local/src/index.ts) and [mcp-resources](../../../../packages/mcp/mcp-resources/src/index.ts) when updating this shared constructor, and update jobs-local's adjacent notification explanation. Preserve shared effects, action rollback, undo ordering, layer reclamation, and `tools/change` with its production consumers. Keep prompt-provider evaluation, scope shadowing, and assembly invariants.
+
+Implementation must amend the active Remote event delivery note to state that this proposal supersedes only its promise to retain `system-prompt/change`. Remove that event from the retained-extension statement and link the implemented retirement decision. Keep the Remote delivery note active and cross-linked: its forwarding policy and other extensions remain independently useful. Do not rewrite or archive that entire decision.
+
+## Alternatives considered
+
+**Keep the extension for future dashboards or plugins.** Push observation could support a real consumer, but none is identified in supported product paths. Retention preserves notification and listener-failure behavior that the package otherwise does not need.
+
+**Treat per-step assembly as proof of compatibility.** Rejected because model-input reconstruction and registry notifications are different behaviors. This proposal deliberately changes the extension promise rather than claiming the earlier reconstruction decision already removed it.
+
+## Acceptance criteria
+
+- Remove the event from source and current generated inventories, and amend the active retention promise in the implementation change.
+- Remove notification-count and notification-failure tests; retain provider-membership, disposer, duplicate-registration, shadowing, and HMR coverage. Keep mixed tests' independent assertions.
+- Run focused scope, tools, system-prompt, jobs-local, and mcp-resources tests, loop request-reconstruction coverage, a relevant keyless recording, catalog generation, typecheck, lint, and doc-sync. Prompt/log output remains unchanged.
+
+## Risks
+
+Installed plugins may depend on notifications or on listener exceptions rejecting registration. They lose both behaviors; repository searches cannot establish their absence. A future push consumer needs an explicit registration-observation requirement and ownership design before reintroducing an event. Source savings are small; the benefit is retiring the public notification and failure obligation.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md
new file mode 100644
index 0000000000..a49df2eeba
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 停用提示词注册表变更事件
+
+Status: proposed
+
+[English](2026-09-19-retire-prompt-registry-change-event.md) | 中文
+
+## 问题
+
+[`SystemPrompt`](../../../../packages/core/system-prompt/src/index.ts) 在提供方注册或 dispose(资源释放)时发出 `system-prompt/change`。仓库搜索未找到执行的产品监听者,但生成的 Host 发现目录会展示该事件。测试维护通知次数及监听者抛出异常后的回滚行为。即使没有第一方订阅者,它仍是具有维护义务的公开扩展。
+
+活动的 [Remote 事件投递决策](../../implemented/architecture/2026-08-10-remote-event-delivery.zh.md) 明确保留了这一扩展,尽管没有随产品提供的消费方。逐步骤组装早于该承诺,且用途不同:重新构建模型输入不能替代注册表变更的推送观测。
+
+## 提案
+
+停用 `system-prompt/change`,接受已安装及动态编写的 Host 插件失去提示词注册表推送观测的代价。删除其声明、发出点、当前文档、生成的发现条目和通知专属测试。当前受支持的产品路径均不依赖此扩展。
+
+将 [`ScopedLayers`](../../../../packages/core/scope/src/store.ts) 构造函数的通知回调改为可选,并让 `SystemPrompt` 省略它;不提供无操作回调。更新此共享构造函数时,同时删除 [jobs-local](../../../../packages/jobs/jobs-local/src/index.ts) 和 [mcp-resources](../../../../packages/mcp/mcp-resources/src/index.ts) 中现有的无操作实参,并更新 jobs-local 相邻的通知说明。保留共享 effect、操作回滚、撤销顺序、层回收,以及 `tools/change` 及其生产消费方。保留提示词提供方求值、作用域覆盖和组装 invariant。
+
+实现时必须修订活动的 Remote 事件投递记录,说明本提案只取代其中保留 `system-prompt/change` 的承诺。从保留扩展的陈述中移除该事件,并链接到已实现的停用决策。让 Remote 投递记录保持活动并相互链接:其转发策略和其他扩展仍具有独立价值。不要重写或归档整个决策。
+
+## 考虑过的替代方案
+
+**为未来仪表盘或插件保留扩展。** 推送观测可能服务于真实消费方,但受支持的产品路径中尚未找到这样的消费方。保留它会维持这个包原本不需要的通知及监听者失败行为。
+
+**将逐步骤组装视为兼容性证据。** 不采用,因为模型输入重建和注册表通知是不同的行为。本提案有意改变扩展承诺,而不声称早先的重建决策已经删除了它。
+
+## 验收标准
+
+- 从源代码和当前生成目录中删除该事件,并在实现变更中修订活动记录中的保留承诺。
+- 删除通知次数和通知失败测试;保留提供方成员关系、disposer、重复注册、作用域覆盖及 HMR(热模块替换)覆盖。保留混合测试中的独立断言。
+- 运行针对作用域、工具、系统提示词、jobs-local 和 mcp-resources 的测试、循环请求重建覆盖、相关无密钥录制回放、目录生成、类型检查、lint 和 doc-sync(文档同步门禁)。提示词和日志输出保持不变。
+
+## 风险
+
+已安装插件可能依赖通知,或依赖监听者异常使注册失败。这两种行为都会消失;仓库搜索无法确认此类插件不存在。未来的推送消费方需要先明确注册观测需求和所有权设计,才能重新引入事件。源代码节省不多;收益在于停用公开通知及其失败义务。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.i18n.yaml
new file mode 100644
index 0000000000..bed94c3f36
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md
+2026-09-19-shell-env-key-declarations.md: 7b74e55e6b8602cb488d16040d57fe41d16e14da
+2026-09-19-shell-env-key-declarations.zh.md: 03d7ed9fdee1c1181c529c2b2dd26ceb7c791ef2
diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md
new file mode 100644
index 0000000000..7b74e55e6b
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md
@@ -0,0 +1,34 @@
+# Agent Note: Keep shell-environment declarations focused on key ownership
+
+Status: proposed
+
+English | [中文](2026-09-19-shell-env-key-declarations.zh.md)
+
+## Problem
+
+The [shell-environment registry](../../../../packages/shell/shell-env/src/index.ts) requires a description for every declared variable and exposes `list()` to enumerate it. Runtime collection uses only contributor identity, declared keys, and resolved values. [Bash](../../../../packages/shell/tool-bash/src/index.ts) and [PowerShell](../../../../packages/shell/tool-pwsh/src/index.ts) call `collect`; the [Web bundle](../../../../packages/bundle/web-app/src/index.ts) contributes `DSH_WEB_URL`. Searches found no fixed production caller of `list()` or consumer of its descriptions.
+
+The method is nevertheless discoverable through the [Cordis inspection provider](../../../../packages/extensions/tool-cordis/src/providers.ts), so this is an explicit API contraction, not unreachable-code removal. The [original identity-and-log-location record](../../archived/feature/2026-07-10-agent-session-identity-and-log-location.md) anticipated diagnostics and future prompt/UI consumers. The current TODO and [README limitation](../../../../packages/shell/shell-env/README.md) still describe an incomplete catalog that omits built-in variables.
+
+## Proposal
+
+Represent a contributor's declaration as an explicit readonly collection of keys. Remove description objects, `BashEnvVariable`, `BashEnvVariableInfo`, `list()`, description-only validation, and the exhaustive-catalog TODO. Update the Web contributor, README pair, and generated service/type declarations together.
+
+Keep `register`, `collect`, reserved keys, ownership conflicts, undeclared-output refusal, deterministic environment output, and effect disposal. The [PowerShell parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) continues to own the shared registry and both shell consumers. The proposal removes one public method, two metadata types, and their validation/enumeration tests; roughly 32 identified source lines disappear before the small key-membership adaptation.
+
+## Alternatives considered
+
+**Finish the diagnostics catalog.** That would add built-in metadata and a consumer capability without an identified current product requirement. The proposal instead gives up declarative environment descriptions and enumeration; actual shell environment values remain available through execution.
+
+**Keep descriptions after deleting `list()`.** Rejected because they would have no remaining reader while still burdening every contributor. Preserve the key ownership declaration independently of presentation metadata.
+
+## Acceptance criteria
+
+- Recheck fixed callers, recordings, generic inspection, and current product requirements before implementation; retain the API if an actual diagnostics consumer now needs it.
+- Contributors declare keys without description objects. Duplicate names, reserved keys, cross-contributor conflicts, undeclared outputs, and disposal retain their behavior.
+- Bash and PowerShell receive equivalent environment maps, including Web URL and Session identity; no secret-handling or ambient-environment policy changes.
+- Run registry, Web contributor, and shell integration tests; regenerate and validate inspection catalogs and any affected recorded inspection output; update both READMEs and run typecheck, doc-sync, and lint.
+
+## Risks
+
+Model-written and external plugins can already discover and call `list()`. They lose enumeration without resolver execution and must change contributor declarations. This pre-stable API loss is acceptable only while the anticipated diagnostics capability remains unowned; `collect()` is not advertised as an equivalent diagnostic replacement.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md
new file mode 100644
index 0000000000..03d7ed9fde
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md
@@ -0,0 +1,34 @@
+# Agent Note: 让 shell 环境声明只负责键所有权
+
+Status: proposed
+
+[English](2026-09-19-shell-env-key-declarations.md) | 中文
+
+## 问题
+
+[shell 环境注册表](../../../../packages/shell/shell-env/src/index.ts) 要求每个声明变量提供描述,并暴露 `list()` 枚举它。运行时收集仅使用贡献方身份、声明键和解析后的值。[Bash](../../../../packages/shell/tool-bash/src/index.ts) 与 [PowerShell](../../../../packages/shell/tool-pwsh/src/index.ts) 调用 `collect`;[Web 组合包](../../../../packages/bundle/web-app/src/index.ts) 贡献 `DSH_WEB_URL`。搜索未发现 `list()` 的固定生产调用方或其描述的消费方。
+
+但该方法可通过 [Cordis 检查提供方](../../../../packages/extensions/tool-cordis/src/providers.ts) 发现,因此这是显式 API 收缩,而非删除不可达代码。[最初的身份与日志位置记录](../../archived/feature/2026-07-10-agent-session-identity-and-log-location.md) 预期它会服务于诊断以及未来提示/UI 消费方。当前 TODO 与 [README 限制](../../../../packages/shell/shell-env/README.zh.md) 仍描述一个未包含内置变量的不完整目录。
+
+## 提案
+
+将贡献方声明表示为显式只读键集合。删除描述对象、`BashEnvVariable`、`BashEnvVariableInfo`、`list()`、仅针对描述的校验,以及完整目录 TODO。同时更新 Web 贡献方、README 配对与生成的服务/类型声明。
+
+保留 `register`、`collect`、保留键、所有权冲突、拒绝未声明输出、确定性的环境输出和 effect 释放。[PowerShell 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md) 继续拥有共享注册表及两个 shell 消费方。提案删除一个公共方法、两个元数据类型及其校验/枚举测试;在少量键成员判定适配前,已定位可删除约 32 行源码。
+
+## 考虑过的替代方案
+
+**补完诊断目录。** 这会在没有明确当前产品需求的情况下增加内置元数据及消费能力。提案选择放弃声明式环境描述和枚举;实际 shell 环境值仍可通过执行获得。
+
+**删除 `list()` 后保留描述。** 否决,因为描述将没有剩余读取方,却仍给每个贡献方增加负担。键所有权声明应独立于展示元数据保留。
+
+## 验收标准
+
+- 实现前重新检查固定调用方、记录、通用检查与当前产品要求;若已有实际诊断消费方需要,则保留 API。
+- 贡献方声明键而无需描述对象。重复名称、保留键、跨贡献方冲突、未声明输出及释放保持原有行为。
+- Bash 与 PowerShell 获得等价的环境映射,包括 Web URL 和 Session 身份;不改变密钥处理或继承环境策略。
+- 运行注册表、Web 贡献方与 shell 集成测试;重新生成并校验检查目录及受影响的检查输出记录;更新两个 README,运行 typecheck、doc-sync(文档同步门禁)和 lint。
+
+## 风险
+
+模型编写的插件与外部插件已经能够发现和调用 `list()`。它们会失去不执行解析器的枚举能力,并需要修改贡献方声明。只有预期的诊断能力仍无归属时,这种稳定前 API 损失才可接受;不能将 `collect()` 宣称为等价诊断替代。
diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.i18n.yaml
new file mode 100644
index 0000000000..676a701835
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md
+2026-09-19-trim-session-query-convenience-api.md: 1a3d558c9be5e16b5e9e84a59a9731b55174114c
+2026-09-19-trim-session-query-convenience-api.zh.md: 63c3ae309edee8366eae4d5db10cc59f0b72e843
diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md
new file mode 100644
index 0000000000..1a3d558c9b
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md
@@ -0,0 +1,35 @@
+# Agent Note: Trim unused SessionQuery convenience methods
+
+Status: proposed
+
+English | [中文](2026-09-19-trim-session-query-convenience-api.zh.md)
+
+## Problem
+
+[`SessionQueryEngine`](../../../../packages/session-query/session-query/src/index.ts) maintains `readSession`, `readTitle`, `readTitleSnapshot`, and `listEvents` without executing first-party product callers. Exact-name and call-site searches find internal delegation, tests, and generated discovery metadata. Current consumers use retained observations, batch titles, surface reads, and traces. The generated catalog exposes these methods to plugin authors but does not execute the advertised operations.
+
+These entry points add replay-validation and result-copying paths, wrappers, public types, and endpoint-specific tests. Their removal can reduce maintained API obligations without replacing the shared query implementation.
+
+## Proposal
+
+Remove the four methods, the unreferenced [`SessionLogSnapshot`](../../../../packages/session-query/session-query/src/types.ts) result type, and [`eventRecords`](../../../../packages/session-query/session-query/src/tracing.ts). Remove their imports and current documentation, including the `SessionLogSnapshot` type-equivalence block and explanatory paragraph in both subsystem pages. Remove its hand-maintained entry in the [type-equivalence manifest](../../../../scripts/type-equiv.manifest.json) and its `LINK_MAP` entry in the [catalog generator](../../../../scripts/gen-cordis-catalog.ts), then regenerate discovery metadata. The selected definitions occupy approximately eighty source lines before import and metadata cleanup; no replacement service is needed.
+
+Retain `filterEvents` and `_filterEvents`, together with their semantic extraction and filtering helpers. Retain `observeSession`, `readTitleSnapshots`, `readSurface`, `readEvent`, listing, search, and tracing. Preserve shared corpus resolution and its existing ownership and failure behavior.
+
+The [historical unified-query decision](../../archived/architecture/2026-07-23-unified-session-query-service.md) explains the retained single-service topology. This proposal removes selected convenience operations, not that topology. No active note is fully superseded; keep archived records frozen and link this narrower decision from affected current documentation.
+
+## Alternatives considered
+
+**Retain all documented methods for external callers.** This avoids migration for installed plugins, but keeps four APIs without a current product caller. The pre-stable API policy permits a deliberate removal with explicit migration costs.
+
+**Remove `filterEvents` too.** Rejected because its provider-independent literal substring scan differs from FTS token matching. The [SQLite query documentation](../../../../packages/session-query/session-query-sqlite/README.md) explicitly recommends this operation; removing it would withdraw a distinct query capability.
+
+## Acceptance criteria
+
+- Remove the four methods and orphan definitions from source, package READMEs, subsystem declarations, and generated catalogs; retain literal substring queries.
+- Preserve live-source precedence, corruption refusal, cancellation, detached results, cold reads, and batch-title behavior through retained endpoints. Move relevant assertions from removed-method tests instead of deleting their shared guarantees; account for `readSession`'s additional replay validation.
+- Run focused query, SQLite-query, tool-query, and affected context-reference tests, relevant keyless query recordings, catalog generation, typecheck, lint, and doc-sync. Released Session generations remain unchanged.
+
+## Risks
+
+Repository searches cannot enumerate installed or dynamically authored plugins. Such callers lose complete-log and lightweight-record convenience operations and must adopt retained APIs with their actual ownership and error semantics. A demonstrated consumer requiring a removed operation would justify reconsidering that operation separately.
diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md
new file mode 100644
index 0000000000..63c3ae309e
--- /dev/null
+++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 精简未使用的 SessionQuery 便利方法
+
+Status: proposed
+
+[English](2026-09-19-trim-session-query-convenience-api.md) | 中文
+
+## 问题
+
+[`SessionQueryEngine`](../../../../packages/session-query/session-query/src/index.ts) 维护了 `readSession`、`readTitle`、`readTitleSnapshot` 和 `listEvents`,但没有执行这些方法的第一方产品调用方。精确名称和调用点搜索只找到内部委托、测试和生成的发现元数据。当前消费方使用可保留的观测、批量标题、模型可见事件读取和追踪。生成的目录向插件作者展示这些方法,但不执行其中列出的操作。
+
+这些入口增加了回放验证和结果复制路径、包装层、公开类型及入口专属测试。删除它们可以减少需要维护的 API 义务,无须替换共享查询实现。
+
+## 提案
+
+删除这四个方法、不再被引用的 [`SessionLogSnapshot`](../../../../packages/session-query/session-query/src/types.ts) 结果类型和 [`eventRecords`](../../../../packages/session-query/session-query/src/tracing.ts)。删除相关导入及当前文档,包括两个子系统页面中的 `SessionLogSnapshot` 类型等价代码块和说明段落。删除[类型等价 manifest](../../../../scripts/type-equiv.manifest.json) 中手工维护的条目,以及[目录生成器](../../../../scripts/gen-cordis-catalog.ts) 中的 `LINK_MAP` 条目,然后重新生成发现元数据。选定定义在清理导入和元数据之前约占八十行源代码;无须新增替代服务。
+
+保留 `filterEvents` 和 `_filterEvents`,以及语义提取和过滤辅助函数。保留 `observeSession`、`readTitleSnapshots`、`readSurface`、`readEvent`、列举、搜索和追踪。保留共享会话集合解析及其现有所有权和失败行为。
+
+[历史上的统一查询决策](../../archived/architecture/2026-07-23-unified-session-query-service.md) 解释了保留的单服务结构。本提案删除选定的便利操作,不改变该结构。没有活动记录被完全取代;保持归档记录冻结,并从受影响的当前文档链接到这一较窄的决策。
+
+## 考虑过的替代方案
+
+**为外部调用方保留所有已记录的方法。** 这样可以避免已安装插件迁移,但会保留四个没有当前产品调用方的 API。尚未稳定的 API 策略允许在明确迁移代价后有意删除它们。
+
+**同时删除 `filterEvents`。** 不采用,因为其提供方无关的字面子串扫描不同于 FTS 的 token 匹配。[SQLite 查询文档](../../../../packages/session-query/session-query-sqlite/README.zh.md) 明确推荐该操作;删除它会撤销一项独立的查询能力。
+
+## 验收标准
+
+- 从源代码、包 README、子系统声明和生成目录中删除这四个方法及失去引用的定义;保留字面子串查询。
+- 通过保留的入口维持实时来源优先、损坏拒绝、取消、结果独立复制、冷读取和批量标题行为。将被删方法测试中的相关断言迁移出去,不删除其共享保证;明确处理 `readSession` 额外执行的回放验证。
+- 运行针对查询、SQLite 查询、查询工具及受影响的会话引用的测试,相关无密钥查询录制回放、目录生成、类型检查、lint 和 doc-sync(文档同步门禁)。已发布的 Session 代际文件保持不变。
+
+## 风险
+
+仓库搜索无法枚举已安装或动态编写的插件。这些调用方将失去完整日志和轻量事件记录的便利操作,必须按照保留 API 实际的所有权和错误语义迁移。若出现确实需要某个被删操作的消费方,可以单独重新考虑该操作。
diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml
index 785297bf28..c0c61084e3 100644
--- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml
+++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.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/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
-2026-08-04-artifact-first-npm-baseline-publication.md: ab4171c60dea47dbdf8fbd7b9d32a41abf36407d
-2026-08-04-artifact-first-npm-baseline-publication.zh.md: 8b1faf75e1ecb616d2895b4f3d123fff1f37fbf6
+2026-08-04-artifact-first-npm-baseline-publication.md: 1c317176521afd2e11707f954520039a3f229a44
+2026-08-04-artifact-first-npm-baseline-publication.zh.md: c2822ed0b572b60fe23cab7c5dc1ebfbe02230f7
diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
index ab4171c60d..1c31717652 100644
--- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
+++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
@@ -18,7 +18,7 @@ The publication flow uses an immutable release bundle as its boundary. The pack
The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list.
-The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `--`. The dist-tag is derived as `dev-`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
+The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `--`. The dist-tag is derived as `dev-`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and short SHA `` produce version `0.0.1-20260804003200-` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
The pack phase runs in this order:
diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md
index 8b1faf75e1..c2822ed0b5 100644
--- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md
+++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md
@@ -18,7 +18,7 @@ monorepo 中可运行的源码并不能证明发布后的包可运行。workspac
目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。
-预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`--`。dist-tag 由基础版本派生为 `dev-`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
+预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`--`。dist-tag 由基础版本派生为 `dev-`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和短 SHA `` 生成版本 `0.0.1-20260804003200-` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
pack 阶段按以下顺序执行:
diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md
index ef20de8097..6052cbf1fc 100644
--- a/.agents/skills/dsh-archive-agent-notes/SKILL.md
+++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md
@@ -1,6 +1,6 @@
---
name: dsh-archive-agent-notes
-description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules.
+description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, deletes small UI and purely mechanical records, classifies other implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules.
---
# Archive DeepSeek Harness Agent Notes
@@ -13,14 +13,15 @@ Read [the Agent Note rules](../../notes/README.md), [the archive instructions](.
## Check supersession when adding a note
-Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit.
+Before drafting, apply the [creation criteria](../../notes/README.md#when-to-write-one). Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit.
## Classify by future value
Apply these lifecycle-specific outcomes:
-- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter.
-- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere.
+- **Implemented — delete:** delete complete triplets that only describe small UI adjustments or purely mechanical changes, and repair or remove inbound links. Local bug fixes, performance changes, new capabilities, and substantive behavior or ownership decisions do not qualify merely because they are implemented. Apply this criterion before keep/archive classification.
+- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Classify the decision, not the mechanics of its implementation: a mechanical rename or type extraction can still record lasting naming, compatibility, or ownership rules. Length does not matter.
+- **Implemented — archive:** archive a substantive historical decision when it is complete and unlikely to guide future work, but its historical rationale still warrants preservation. Do not archive records that meet the direct-deletion criterion.
- **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format.
- **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses.
- **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links.
@@ -31,11 +32,10 @@ Do not archive toward a quota. Inspect every note in scope, classify analogous g
These examples set the bar; the word counts demonstrate that size is not the test.
-Archive implemented notes such as:
+Delete implemented notes such as:
- collapsed sidebar control rail — 533 words: closed, minor UI behavior;
-- Commander argument adapter — 1,498 words: substantial implementation detail with little future design leverage;
-- documentation graph atlas — 920 words: completed documentation machinery whose current generators are authoritative.
+- issue-policy module ownership — completed function relocation and import rewiring with unchanged behavior.
Keep implemented notes such as:
@@ -55,7 +55,7 @@ For rejected notes:
1. Move the complete `foo.md`, `foo.zh.md`, and `foo.i18n.yaml` triplet from `implemented//` to `archived//`; `implemented` is deliberately absent from the archive path.
2. Make no body edits. Insert only `Archived: YYYY-MM-DD` immediately below `Status: implemented` in both language files, using the archival date and the same value on both sides.
-3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note.
+3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. Existing title punctuation, blank-line layout, and language-switcher wording are preserved, not prerequisites for archival.
4. Search for inbound links from active prose. Redirect them to current authority, retarget them to the archived path only when the historical snapshot is intentionally cited, or delete them. Never verify or repair links out of the archived note.
5. Run `pnpm run verify-archived-agent-notes --write`. Its append-only mode first proves every existing seal still matches, then adds only the new triplet hashes. Run the normal verifier afterward.
@@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it
Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md).
-Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them.
+Report active implemented notes kept, implemented notes deleted or archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them.
diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md
index 6ee736796c..53444db411 100644
--- a/.agents/skills/dsh-find-simplifications/SKILL.md
+++ b/.agents/skills/dsh-find-simplifications/SKILL.md
@@ -1,157 +1,67 @@
---
name: dsh-find-simplifications
-description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, remove redundant comments or implementation-heavy documentation, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.'
+description: Find evidence-backed simplifications in DeepSeek Harness code, APIs, configuration, tests, and prose; write or consolidate proposals, identify small inline cleanups, or assess simplifications from another branch. Use for removing dead, duplicated, speculative, or unnecessarily maintained behavior and infrastructure.
---
# Finding DeepSeek Harness Simplifications
-This skill helps turn a broad "find things to simplify" request into evidence-backed Agent Notes that remove or collapse existing harness surface area. It is guidance, not a checklist: follow the code, keep judgment active, and prefer a few well-proven candidates over a pile of thin guesses.
+Find changes that remove maintained obligations: APIs, representations, lifecycle states, configuration paths, dependencies, tests, or documentation. Prefer a few well-supported candidates over a count of deletions. This is guidance, not a checklist; keep the user's scope and distinguish a survey from permission to implement its proposals.
-## Start With Repo Context
+## Establish scope and constraints
-- Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md).
-- Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence.
-- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/archived/simplification/2026-06-19-drop-mutable-session-summary.md), [handle-based session persistence](../../notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md), [JSONL-only first-party Session persistence](../../notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin-adapter Agent Notes.
-- Treat dual LLM adapters as intentional by default. Session persistence is different: JSONL is the sole first-party provider, while the backend-neutral service remains available to out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design.
+Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links.
-## What Counts As A Strong Candidate
+The two LLM adapters are intentional. JSONL is the sole first-party Session persistence provider, while the backend-neutral Service Definition supports out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam unless the user explicitly overrides that constraint. Unused members within a protected design remain candidates when their removal preserves its purpose.
-A strong simplification removes, folds, or demotes something real and has clear evidence that the current design costs more than it buys:
+For broad requests, divide independent domains among subagents: loop and persistence; model/tool assembly; Host, Client, and SDKs; subprocess and execution providers; composition, scripts, tests, and documentation. Give each agent a bounded domain and require consumer evidence and rejected alternatives. Inspect substantial production machinery as well as obvious unused symbols; do not stop after the first promising deletion.
-- A public method, event, config knob, registry notification, helper, package, durable event, or test artifact has no production consumer.
-- Tests or docs are the only consumers, and the behavior they pin is not load-bearing.
-- Two representations mirror the same fact, especially across durable session events and transient `agent/*` events.
-- A seam has methods every implementation must support but no consumer uses.
-- A separate package exists only for test/demo/support code and adds publish or dependency overhead.
-- A feature implements speculative product generality: multi-session/session-load, background job rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar designs with no product owner.
-- An invariant, rollback path, set of expected outputs, or special-case test exists only to protect an unused API.
-- Hand-rolled code reimplements what a well-maintained external package or a Node builtin at the engine floor already provides, and the swap would delete the implementation plus its dedicated tests ([dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)).
-- The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain.
+## Search for removable obligations
-Thin candidates are not enough for an Agent Note: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof.
+Use these questions to guide discovery. Read the relevant section of [historical patterns](references/historical-patterns.md) when a candidate needs calibration; the examples illustrate decisions, not a current deletion inventory.
-### Audit invariant companions
+- **Does a declared feature have a complete effect path?** Trace producer, transformations, provider or endpoint support, and observable result. A field copied everywhere may still have no setter, no reader, or only implementations that reject it. Search constructors and discriminant emitters as well as callers.
+- **Which distinctions change a consumer's action?** Several internal states may require one model-facing status or one usable identifier. Keep internal distinctions that control authorization, residency, durability, or ownership; remove public distinctions that only invite an unusable choice.
+- **Could a smaller explicit behavior remove the subsystem?** Fixed intervals, caller-supplied time zones, or reported activation failure can eliminate generalized calendars, persisted defaults, or automatic rollback. Name the capability given up and the machinery deleted. A production caller makes this a behavior decision, not an automatic rejection; implementation must remain within the user's authorization.
+- **Can a consumer read the authoritative value when needed?** Look for copied histories, promotion ledgers, invalidation events followed by reads, and feature caches beside a shared projection stream. Derivation or reuse may delete subscriptions, retained data, stale-response handling, and recovery paths together. Establish the required time of observation first.
+- **Is composition being mistaken for policy?** Optional service presence, a provider method, or a sibling tool can express availability without deciding deployment behavior. Explicit configuration or a caller-intent operation may remove registries and dependency checks. Preserve distinct operations when their ownership or completion timing differs.
+- **What owns the complete maintenance cost?** Trace pass-through configuration, duplicate application trees, demo launchers used by products, resolver-only workspaces, and installation helpers with their own upgrade lifecycle. Compare the entire removed system with the replacement, including residual glue; moving complexity or adding a gate to keep duplicate definitions equal does not remove it.
-Treat an invariant companion as useful only when it compares independently produced observations that can diverge. Remove empty installers and checks that merely inspect service presence, plugin metadata, fixed examples, or the result of calling the same mutation they claim to verify. For every omission, remove the export, build entry, invariant-only compiler reference or dependency, and companion-only test, then record the package-specific reason in both package READMEs. Keep a companion when it compares distinct event producers, durable history, or independently mutable data, even if the package also validates inputs synchronously.
+## Prove reachability and the trade-off
-## Survey Broadly
+Start with `rg`, then read the matches. Search exact symbols, property reads and writes, discriminants, event and wire strings, config keys, package names, and both `.method(` and `method(` forms. Tests and declarations can show a contract without proving a shipped producer or consumer.
-Use parallel subagents when the user asks for breadth or many candidates. Give each agent a domain and require evidence, not guesses. Useful domains:
+Include `packages/*/*/src`, `apps/`, `python/`, runtime scripts, shipped profiles and overlays, package manifests and exports, loader resolution, generated runtime assets, and installed-package consumers. Classify examples and fixtures by their actual entry point rather than directory name. Follow generated catalogs to their consumers: a catalog used by model-written plugin mounts is a dynamic product API, while a documentation-only listing is not a fixed caller. Public third-party extension paths can be intentional despite zero repository call sites.
-- Agent loop and session log: turn/step boundaries, steering, abort/cancel, durable events, replay, load/resume.
-- ACP automation and human UI APIs: prompt settlement and teardown on the protocol side; transcript rendering and interaction state on the UI side.
-- LLM/tools/system prompt: stream/generate APIs, assemblers, registries, tool schema defaults, presentation hooks.
-- Bash and tool execution: foreground/background split, job ownership, output spill files, executor methods.
-- Packages/examples/scripts/tests: package splits, static inventories, redundant snapshot expected outputs, support packages.
+For each candidate, record the current owner, effective producer/consumer path, what disappears, what remains, and the strongest reason to retain it. Distinguish:
-If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey.
+- removal of unreachable or unread behavior;
+- a narrower public or product behavior with an explicit loss;
+- a protected obligation or insufficient evidence.
-Start with the largest production-code deltas. A broad simplification audit that stops after obvious unused symbols can miss the files where duplicated lifecycle or defensive machinery carries most of the cost.
+Reject a candidate when it breaks a retained obligation, merely relocates the same complexity, or has no meaningful reduction. Read rejection reasons at the level of the proposed change: a stale inventory or a mixed proposal's rejected behavior does not settle every independent item. Refresh the evidence before re-proposing one. Small local improvements belong in actionable `TODO`/`FIXME`/`XXX` comments under the [urgency rules](../../../docs/development.md), not standalone design records.
-## Simplify Prose With The Code
+## Preserve ownership and failure semantics
-Treat comments and documentation as maintained surface area. Apply [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when a survey includes prose.
+For copies, freezes, validators, and callback captures, name the value's origin, next owner, and trust boundary. Typed same-process calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their inputs. Hostile-getter or callback-replacement tests do not establish a requirement by themselves. A frozen root does not prove its descendants immutable, and equal ids do not prove object identity.
-- Delete comments that restate code or explain behavior owned elsewhere; keep required local contracts.
-- Keep docs at their owning level; omit implementation details and rare cases unless they change a maintained contract.
+For asynchronous machinery, map each promise, flag, cancellation path, disposer, and reservation to its owner and transition. Collapse mechanisms only when they express the same fact. Preserve synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, and dispose-to-quiescence where required. Durable start/end markers can record real facts even when transcript renderers ignore them; dropping interrupted work is a capability loss, not redundant-log cleanup.
-## Audit Trust And Lifecycle Boundaries
+An invariant companion is justified by comparing independently produced observations that can diverge. Checks of service presence, plugin metadata, fixed examples, or the same mutation's return value do not qualify. Removing a companion includes its export, build entry, invariant-only compiler references, dependencies, and tests; record the package-specific omission reason in both READMEs. Keep checks comparing independent event producers, durable history, or mutable data.
-For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it.
+## Replace infrastructure only for a net reduction
-For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence.
+Follow the [dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md). Search existing dependencies and Node builtins at the engine floor before adding a package. Identify the exact implementation and dedicated tests the replacement deletes, its maintenance and transitive cost, and every residual behavior the glue must preserve.
-## Hand-Rolled Code Versus A Dependency
+Exercise the mismatches most likely to defeat the replacement: protocol truncation, cancellation and teardown, configured bounds, deterministic test clocks, platform packaging, and source versus built entry points. A library that covers framing but requires rebuilding the same framing to enforce a size limit is not a simplification. An accepted edge-behavior change belongs in the proposal and relevant tests. Do not re-prove a dependency's generic specification while omitting tests for the harness-specific shim.
-Introducing a dependency is a valid simplification move, not a policy exception: the [dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md) owns the bar. When surveying, ask of protocol parsers, framers, retry/backoff loops, glob matchers, diff engines, and similar infrastructure: does a well-maintained npm package or a Node builtin at the repo's engine floor already do this?
+## Record, consolidate, and validate
-Prove a dependency-swap candidate like any other, plus:
+Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when prose is in scope. Remove code-restating or remotely owned explanations while preserving local behavior, timing, ownership, and failure obligations. Update affected READMEs, JSDoc, model-visible catalogs, configuration, snapshots, and generated files with their owning change.
-- Read the hand-rolled implementation and name the exact surface the package covers; residual semantics the package does not cover count against the swap and stay in the Agent Note.
-- Check the package's health honestly (maintenance, adoption, transitive footprint) and prefer builtins when the engine floor has them.
-- Check the Agent Note tree first: schemastery, vendored Cordis, the twin adapters, and other recorded seams are settled — a swap that collapses one needs to beat the recorded rationale, not just cite the policy.
-- Weigh net deletion: implementation plus dedicated tests plus docs, minus the glue that remains. A wrapper that relocates the same complexity is not a win.
+A substantial proposal uses the mandatory note skeleton: `Problem`, `Proposal`, `Alternatives considered`, `Acceptance criteria`, and `Risks`. Include concrete consumer evidence, the removed maintenance cost, the capability given up, and observable acceptance conditions. An implemented decision uses the [implemented format](../../notes/README.md#the-body-skeleton) instead. Update an existing owner when the decision is the same; do not create duplicate notes to preserve candidate counts.
-## Prove Or Reject Each Candidate
+Every new note requires a scoped supersession check through [dsh-archive-agent-notes](../dsh-archive-agent-notes/SKILL.md). That workflow owns retention, consolidation, triplet deletion, and frozen archive mechanics. A code survey does not imply a repository-wide note audit. Preserve partial supersessions and current durable, wire, compatibility, or rejected-alternative obligations.
-For every symbol or behavior, classify consumers before writing:
+When folding another branch, compare its independent diff against the target base, port only supported non-overlapping proposals, and consolidate overlapping rationale. Closing another PR requires authorization or clear ownership of that housekeeping.
-- Production corpus: `packages/*/src`, `examples/*/src`, `examples/**/*.yml`, runtime scripts, and loader/config paths.
-- Non-production corpus: tests, README/docs, Agent Notes, snapshots, generated expected outputs, and comments.
-- Ambiguous corpus: examples and scripts that may be product smoke paths. Inspect usage before classifying.
-
-Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites, public interfaces, dynamic event names, tests, docs, and Cordis loader paths.
-
-Reject or downgrade a candidate when:
-
-- A production caller exists and the simplification would be a feature decision rather than a cleanup.
-- The API is explicitly justified by an implemented Agent Note or a hard-won defensive pattern, and the new evidence does not beat that reason.
-- The removal would force unrelated churn without actually reducing the public API or required behavior.
-- The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md).
-
-## Coalesce Superseded Agent Notes
-
-Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit.
-
-Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code.
-
-Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain:
-
-1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof.
-2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial.
-3. For full supersession, move every unique rationale, alternative, consequence, shipped verification evidence, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts.
-4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together.
-5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current.
-
-An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification evidence.
-
-Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone.
-
-## Write The Agent Note
-
-Create one file per durable proposal under `.agents/notes///yyyy-mm-dd-topic.md`, following the lifecycle and classification rules in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links.
-
-Prefer this structure, adjusting when the idea needs it:
-
-- `# Agent Note: `
-- `Status: proposed`
-- `## Problem`: name the current API, cite the relevant files, and state the consumer evidence. Separate production callers from tests/docs.
-- `## Proposal`: say exactly what to remove, fold, demote, or rehome. Include tests, docs, READMEs, JSDoc, event-taxonomy, snapshot, and generated-file cleanup when relevant.
-- `## Why not keep it?` or `## What we give up`: make the strongest counterargument legible.
-- `## Acceptance criteria`: observable end state and gates.
-- `## Risks`: public API changes, behavior changes, future product wants, and why the tradeoff is still reasonable.
-
-Be concrete enough that an implementing PR can follow the trail. Avoid vague "simplify this package" Agent Notes. When a proposal overlaps an existing Agent Note, consolidate the useful details into the existing one rather than creating a duplicate.
-
-## Inline TODO Notes
-
-Use inline TODO/FIXME/XXX only for small, local cleanups that are clearly useful but not durable design decisions. Keep them short and actionable:
-
-- Name the smell with a stable tag, e.g. `TODO(double-default)` or `XXX(unused-default)`.
-- Explain why it is safe to revisit and what action would simplify it.
-- Do not add TODOs for speculative complaints or for behavior that needs an Agent Note-level decision.
-
-## When Folding Another PR Or Branch
-
-Diff the sibling branch against `origin/master`, not against the current PR branch, so you see its independent contribution. For each item:
-
-- Port non-overlapping Agent Notes or TODOs that meet the quality bar.
-- Consolidate overlapping material into the existing Agent Note that owns the topic.
-- Do not port duplicate or lower-confidence proposals just to preserve the count.
-- Update the PR body so reviewers see the true candidate count and scope.
-- Close the duplicate PR only when the user asked you to, or when you clearly own that housekeeping.
-
-## Validation And PR Hygiene
-
-For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Select any other evidence from the outgoing diff; the pre-push hook contributes typecheck only.
-
-When opening or updating a PR, summarize:
-
-- How many Agent Notes and inline notes were added, consolidated, retained as partial supersessions, or deleted.
-- The main areas surveyed.
-- What was intentionally excluded.
-- Which checks passed.
-
-For each consolidation group, name the old and current owners, state the evidence for full supersession, and explain why deletion is safe. If an added-then-removed scan finds no qualifying note, report that result and the representative partial cases retained.
-
-Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled.
+For docs-only Agent Note work, run `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; for skill or comment edits, include the applicable validator. Select other checks through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). Report the surveyed areas, supported candidates, meaningful rejections or deferrals, notes added/consolidated/retained/removed, and commands actually run. Use a draft PR while the survey is expanding; do not describe an unverified search as exhaustive.
diff --git a/.agents/skills/dsh-find-simplifications/references/historical-patterns.md b/.agents/skills/dsh-find-simplifications/references/historical-patterns.md
new file mode 100644
index 0000000000..f5f91342e4
--- /dev/null
+++ b/.agents/skills/dsh-find-simplifications/references/historical-patterns.md
@@ -0,0 +1,45 @@
+# Historical simplification patterns
+
+Use these examples to calibrate a candidate whose value is not established by an unused-symbol search. They describe recorded decisions, not current removal opportunities. Archived sources are frozen snapshots: read the cited note itself, never follow or repair its outbound links. Verify current owners and released-data obligations before applying a pattern.
+
+## Require a complete effect path
+
+A declaration, serializer branch, and test can form convincing-looking support without an effective feature. Trace both ends: something must produce the value, and an implementation must honor it. The [producerless-variants decision](../../../notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) removed reserved union members and cache hints without emitters. The [inert-request-knobs decision](../../../notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md) distinguished knobs rejected by every adapter from working request fields available to hooks. Deletion included declarations, forwarding, rejection-only tests, and generated documentation.
+
+Zero fixed callers is weaker evidence than unreachability. The [skill-registration rejection](../../../notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md) preserves a deliberate third-party registration path. For catalogued services, inspect generic plugin invocation and returned values before calling them dead. A working extension point and a field no provider can honor require different verdicts.
+
+## Reduce distinctions where consumers decide
+
+Map every public variant to the consumer action it enables. The [model agent-availability decision](../../../notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md) gives models one usable Team target and projects multiple residency states to the same availability, while internal routing retains Session identity and residency. The [capability-neutral sandbox context](../../../notes/archived/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md) replaces two approximate capability registries with truthful conditional policy wording; exact tool availability remains in tool schemas. The gain includes registration methods, disposal state, invalidation, and combination tests.
+
+Do not erase distinct durable facts merely because one renderer ignores them. The [durable step-boundary rejection](../../../notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md) retains explicit start and completion facts needed for empty failed steps, crash repair, and inspection. Simplify the projection only when the underlying distinction remains necessary.
+
+## Trade general behavior for a smaller explicit promise
+
+A used feature can still be an expensive way to satisfy the required outcome. The [fixed-rate Schedule decision](../../../notes/archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md) gives up calendar recurrence and missed-occurrence replay, removing a parser, evaluator, cross-record gate, durable variants, and backlog states. The [non-transactional Loader decision](../../../notes/implemented/simplification/2026-09-09-nontransactional-loader.md) gives up automatic plugin rollback while keeping failure reporting and consumer-owned activation checks. Moving generic rollback to another package would retain its cost.
+
+State the loss as clearly as the deletion. The [interrupted-turn truncation rejection](../../../notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md) rejects a simpler recovery algorithm because an unfinished turn can contain substantial real work. Convenience, smaller unions, or fewer tests alone do not justify losing required data or behavior. A survey can propose the trade-off without implying permission to implement it.
+
+## Make assumptions explicit at their consuming operation
+
+Look for a local default that has acquired shared storage, synchronization, or confirmation machinery. The [explicit Schedule time-zone decision](../../../notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) keeps browser facts on the originating request and requires an explicit zone in the scheduling operation, removing Session defaults and cross-tab conflict state. The [injected-content decision](../../../notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md) leaves framing with the caller and removes envelope controls threaded through unrelated layers. Both reduce the number of owners that must interpret one assumption.
+
+Do not move a requirement past the place that must enforce it. The [Host-only Remote input-validation decision](../../../notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md) removes duplicate Client schema execution but retains Host validation before lookup and business invocation, plus local arity and Context binding checks. Its documented cost is later malformed-input failure and the loss of Client parsing as accidental redaction.
+
+## Derive at use or reuse an existing delivery owner
+
+For each cache or event subscription, ask when the consumer needs the value and whether an authoritative source already retains it. [Feedback telemetry](../../../notes/archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) reads the canonical Session prefix at feedback time, deleting a continuously copied pre-feedback buffer and its listeners. [Workspace recency](../../../notes/implemented/simplification/2026-09-10-derived-workspace-recency.md) sorts current summaries instead of persisting promotion history; manual ordering remains independent user state. [Web subagent projections](../../../notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md) reuse a shared stream and projection store, removing feature notifications, repeated reads, and a second membership cache.
+
+The timing promise determines whether this works. Feedback-time redaction differs from capture-time redaction; manual placement cannot be derived from activity timestamps; an initial historical read may remain necessary when a stream baseline is absent. Name such residual obligations instead of declaring all caches redundant.
+
+## Remove accidental composition owners
+
+Inspect actual launch and resolution paths before trusting names such as demo, support, or example. The [shared-base-config decision](../../../notes/archived/simplification/2026-07-29-shared-base-config-overlays.md) removes duplicate compositions and pass-through settings; actual Loader behavior determines overlay placement. The [owner-local profiles and guides decision](../../../notes/archived/simplification/2026-08-24-owner-local-profile-tests-and-guides.md) removes a resolver-only workspace by assigning tests, guides, and shipped assets to their owners. The [merged subagent service decision](../../../notes/archived/simplification/2026-07-26-merge-subagent-control-service.md) uses explicit deployment policy instead of inferring it from provider or sibling-tool presence, while preserving optional dependencies internally.
+
+A smaller package count is not sufficient. Separate caller intents may have different ownership and completion timing; a retained Service Definition may serve independent providers. Prove the replacement's real composition and dependency behavior, and account for all remaining configuration and lifecycle glue.
+
+## Use rejected experiments to test parity claims
+
+The [SSE parser replacement](../../../notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md) deletes generic framing and its conformance tests while retaining the provider-specific termination shim and documenting stricter truncated-tail behavior. In contrast, the [timer-promises rejection](../../../notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md) records that a nominally equivalent builtin defeated deterministic fake-clock tests for a small deletion. The [dependency-swap audit](../../../notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) distinguishes replaceable generic code from configured bounds, security rules, lifecycle ownership, and deployment behavior a library does not implement.
+
+A past rejection is a scoped falsifier, not a permanent package blacklist. Revisit it only with new evidence addressing the failed premise, and measure the implementation, glue, tests, dependencies, and supported behavior together.
diff --git a/.github/issue-management/README.i18n.yaml b/.github/issue-management/README.i18n.yaml
index 7d31f8418d..beaf888252 100644
--- a/.github/issue-management/README.i18n.yaml
+++ b/.github/issue-management/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 .github/issue-management/README.md
-README.md: a936b9358810fd3a8b8104609a5523e305e1aaa5
-README.zh.md: df743cac581c2125df9ac9e76f04d2528204eb74
+README.md: 49e91859f4b22fda13d0e93c6e6ea492272f6951
+README.zh.md: 8424f90cd2e3a59ba1212f64f3f1081a984b55f8
diff --git a/.github/issue-management/README.md b/.github/issue-management/README.md
index a936b93588..49e91859f4 100644
--- a/.github/issue-management/README.md
+++ b/.github/issue-management/README.md
@@ -61,7 +61,7 @@ Lifecycle processing is event-driven, not a reconciler. Omitted events do not re
## Module ownership
-Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures. The [module-ownership decision](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md) explains this separation.
+Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures.
Implementation owners
diff --git a/.github/issue-management/README.zh.md b/.github/issue-management/README.zh.md
index df743cac58..8424f90cd2 100644
--- a/.github/issue-management/README.zh.md
+++ b/.github/issue-management/README.zh.md
@@ -61,7 +61,7 @@ PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引
## 模块归属
-维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。[模块归属决策](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md)解释此职责分离。
+维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。
实现职责
diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs
index 00f447e682..5792299c46 100644
--- a/.github/review-ownership/check-approval.mjs
+++ b/.github/review-ownership/check-approval.mjs
@@ -319,7 +319,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
for (const reviewId of delegation.reviewIds) {
const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
method: 'PUT',
- body: { message: `This is by automated Angry Turtle Cyborg, not a human. @${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
+ body: { message: `@${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
})
if (!isRecord(dismissed) || dismissed.id !== reviewId || dismissed.state !== 'DISMISSED') {
throw new Error('delegated review dismissal was not confirmed')
diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs
index e4e8c1ce10..b5d0b6cbb3 100644
--- a/.github/review-ownership/check-approval.test.mjs
+++ b/.github/review-ownership/check-approval.test.mjs
@@ -915,7 +915,7 @@ test('delegation dismisses only the sender old decisions and stays active across
})
assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT'])
assert.equal(calls[0].body.event, 'DISMISS')
- assert.equal(calls[0].body.message, 'This is by automated Angry Turtle Cyborg, not a human. @turtle1999 delegated approval to @writer via /delegate.')
+ assert.equal(calls[0].body.message, '@turtle1999 delegated approval to @writer via /delegate.')
assert.deepEqual(result.blockers, ['other'])
assert.equal(result.delegations.length, 1)
assert.deepEqual(result.delegations[0].reviewIds, [])
diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml
index 5042778b72..e53d524ead 100644
--- a/.github/workflows/build-exe-for-python-sdk.yml
+++ b/.github/workflows/build-exe-for-python-sdk.yml
@@ -152,14 +152,15 @@ jobs:
if-no-files-found: error
retention-days: 7
+ # Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes.
build:
needs: [plan, sdk-wheel]
name: ${{ matrix.target }}
runs-on: >-
${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64'
- && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+ && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404'
|| inputs.ci && !inputs.release && matrix.target == 'node24-win-x64'
- && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+ && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025'
|| matrix.runner }}
timeout-minutes: 45
strategy:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2139231c06..32b58e9bc1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,7 +33,7 @@ jobs:
# vm-backup pool and re-running the failed jobs is the entire switch —
# see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md.
# Setting the variable to 'blacksmith' instead routes the same jobs onto
- # Blacksmith's hosted runners at the matching vCPU size (see
+ # Blacksmith's hosted runners sized per workload (see
# .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The
# in-house pool's readiness is re-proven on every master push by the
# serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
@@ -42,14 +42,15 @@ jobs:
node-24:
if: github.event_name == 'pull_request'
runs-on: >-
- ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+ ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404'
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / static
env:
- DSH_GATE_CONCURRENCY: '8'
+ # Shared hosts expose host CPUs, not an isolated allocation.
+ DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
# Stop the aggregate at the first blocking gate failure so a red run
# does not keep burning enterprise runner time on the remaining gates.
DSH_GATE_FAIL_FAST: '1'
@@ -116,11 +117,10 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / coverage
env:
- # Partitioning replaces the instrumented share; this budget gives the
- # exempt-heavy gate two workers on both hosted and failover runners.
- DSH_COVERAGE_MAX_WORKERS: '6'
- DSH_COVERAGE_PARTITIONS: '4'
- DSH_GATE_CONCURRENCY: '3'
+ # Shared hosts expose host CPUs, not an isolated allocation.
+ DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
+ DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
+ DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
# Managed-scope teardown cases run past the default per-test budget when
# this lane's partitions, workers, and sibling gates share one host: the
# disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on
@@ -246,17 +246,17 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / snapshots and artifacts
env:
- DSH_GATE_CONCURRENCY: '10'
+ # Shared hosts expose host CPUs, not an isolated allocation.
+ DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '10' || '' }}
+ DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
+ DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
+ DSH_WEB_SNAPSHOT_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
+ DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '' }}
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
- DSH_OXLINT_THREADS: '8'
- DSH_PUBLINT_CONCURRENCY: '8'
- DSH_WEB_SNAPSHOT_WORKERS: '6'
# A failing gate aborts its running siblings: a failing build stops the
# independent Node compatibility smoke, and a failing reader (e.g.
# publint) stops the remaining artifact consumers.
DSH_GATE_FAIL_FAST: '1'
- # Failover halves snapshot concurrency for the shared 64-core VM.
- DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
# Runner cleanup owns tool caches and fixtures, including cancelled runs.
- name: Use runner-owned temporary storage
@@ -491,12 +491,12 @@ jobs:
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
- # Every pull request also gets real Windows-kernel signals. The former
- # monolithic windows-native job is split into smaller jobs so one slow
- # coverage gate does not hold up build/static results, while the total
- # per-job process count stays lower. Observational checks are non-blocking.
+ # Native builds and process tests block pull requests independently of coverage.
+ # Observational checks reuse the successful build and remain non-blocking.
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
# the hosted runner.
+ # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage
+ # and the shared build/observational workspace.
windows-build:
if: github.event_name == 'pull_request'
runs-on: >-
@@ -505,7 +505,7 @@ jobs:
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|| 'dsh-windows-2025-16core' }}
- name: windows node 24 / build
+ name: windows node 24 / build & observational
timeout-minutes: 60
env:
# A failing build or site aborts the sibling gate on the same runner.
@@ -541,6 +541,23 @@ jobs:
- name: Run blocking Windows builds
shell: pwsh
run: pnpm run check:ci:windows-blocking
+ - name: Run Windows observational gates on built artifacts
+ id: observational
+ continue-on-error: true
+ timeout-minutes: 15
+ shell: pwsh
+ env:
+ DSH_GATE_FAIL_FAST: ''
+ DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
+ run: pnpm run check:ci:windows-observational-ready
+ - name: Report Windows observational failures
+ continue-on-error: true
+ if: steps.observational.outcome == 'failure'
+ shell: pwsh
+ run: |
+ $message = 'Windows observational checks failed; see the observational step for diagnostics.'
+ Write-Output "::warning::$message"
+ $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
windows-coverage:
if: github.event_name == 'pull_request'
@@ -553,10 +570,11 @@ jobs:
name: windows node 24 / coverage
timeout-minutes: 120
env:
- DSH_COVERAGE_MAX_WORKERS: '6'
- DSH_COVERAGE_PARTITIONS: '4'
+ # Shared hosts expose host CPUs, not an isolated allocation.
+ DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
+ DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
+ DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
- DSH_GATE_CONCURRENCY: '3'
# A gate failure aborts the sibling gate instead of waiting out its
# multi-minute instrumented run.
DSH_GATE_FAIL_FAST: '1'
@@ -622,7 +640,7 @@ jobs:
windows-native-tests:
if: github.event_name == 'pull_request'
runs-on: >-
- ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+ ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025'
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
@@ -668,51 +686,6 @@ jobs:
packages/workflow/tool-ralph/tests/integration.spec.ts
packages/subprocess/subprocess-local/tests/process-exit.spec.ts
- windows-observational:
- if: github.event_name == 'pull_request'
- continue-on-error: true
- runs-on: >-
- ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
- || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
- && github.event.pull_request.user.login != 'dependabot[bot]'
- && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
- || 'dsh-windows-2025-16core' }}
- name: windows node 24 / observational
- timeout-minutes: 60
- env:
- DSH_PUBLINT_CONCURRENCY: '8'
- steps:
- - uses: actions/checkout@v6
- with:
- persist-credentials: false
- - name: Enable Developer Mode (symlink support)
- shell: pwsh
- run: >-
- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
- /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- - uses: pnpm/action-setup@v4
- with:
- dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- - uses: actions/setup-node@v6
- with:
- node-version: ${{ env.PRIMARY_NODE_VERSION }}
- - name: Install (immutable)
- shell: pwsh
- # See 2026-08-30-windows-refs-store-block-clone-install for the
- # ReFS block-clone rationale; detect the workspace filesystem and
- # pass --package-import-method=clone only on ReFS.
- run: >-
- $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
- $fs = (Get-Volume -DriveLetter $drive).FileSystem;
- if ($fs -eq 'ReFS') {
- corepack pnpm install --frozen-lockfile --package-import-method=clone
- } else {
- pnpm install --frozen-lockfile
- }
- - name: Run Windows observational gates
- shell: pwsh
- run: pnpm run check:ci:windows-observational
-
# Single stable required check for branch protection: require "all checks
# passed" instead of enumerating matrix legs whose names change as lanes and
# node versions evolve. Every blocking job in THIS workflow must be listed in
diff --git a/.gitignore b/.gitignore
index 71a5aba6f6..7ad33230ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ CLAUDE.local.md
.env
apps/desktop/.env.windows
apps/desktop/.env.macos
+apps/desktop/*.p12
node_modules/
lib/
*.tsbuildinfo
diff --git a/AGENTS.md b/AGENTS.md
index ce7a07db07..7a128b6167 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,6 +114,7 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/
- Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers.
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
+- **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly.
## Secrets / .env
@@ -139,6 +140,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.
- **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`.
- **Trust TypeScript at typed same-process boundaries.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries.
+- **No new assertions to `unknown`** (`as unknown` or ``). Preserve or reduce the exact legacy baseline; use typed values or validation for replacements ([rule](.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md)).
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
- **An empty `catch` names the error** and why; keep its `try` to one statement.
@@ -146,7 +148,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)).
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
-- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
+- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index c26e267921..3402dd18e3 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -76,6 +76,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`commander`](https://github.com/tj/commander.js) | MIT |
| [`compression`](https://github.com/expressjs/compression) | MIT |
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
+| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
@@ -161,7 +162,6 @@ External packages **directly declared** for development, tests, types, or toolin
| Package | License |
| --- | --- |
-| [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 |
| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
| [`@electron/get`](https://github.com/electron/get) | MIT |
| [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
@@ -197,6 +197,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |
+| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC |
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index 1914398ea8..026b49d50b 100644
--- a/apps/cli/README.i18n.yaml
+++ b/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: 68500e54372d16a9ead8e548eed5a7e4836be3fb
-README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e
+README.md: 46c6a66336839db443a825c66ebfd71dcce398af
+README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 68500e5437..46c6a66336 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -55,6 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
-The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
+The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
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.
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index a1002812c3..14838114b0 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
-`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
+`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
[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 的必需依赖与端口冲突。
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 1945ba8424..400bc6621f 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
- "version": "0.1.6-alpha.1",
+ "version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -48,7 +48,6 @@
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
- "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml
index f5eb1cde22..0cff0e5cdb 100644
--- a/apps/cli/reference/README.i18n.yaml
+++ b/apps/cli/reference/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: 6429b2872ec393304a7d3a76afd8194cb74d7698
-README.zh.md: c9d9bee8ad84e3fb0c3f4d82b295c743f5a5ad99
+README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899
+README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664
diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md
index 6429b2872e..cf0e1400f2 100644
--- a/apps/cli/reference/README.md
+++ b/apps/cli/reference/README.md
@@ -8,7 +8,7 @@ This reference defines the profile, plugin-management, and config-dump command m
`dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
-Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
+Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable runtime resolution into Node's resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution.
The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile add `.
@@ -48,7 +48,7 @@ dsh --profile web --dump-default-config
dsh --profile web --patch ./extra.yml --dump-config
```
-`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
+`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
## Startup diagnostics
@@ -116,4 +116,4 @@ Install external plugin bundles through `dsh plugin --profile add
## Source execution
-From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository.
+From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository.
diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md
index c9d9bee8ad..a01818f59c 100644
--- a/apps/cli/reference/README.zh.md
+++ b/apps/cli/reference/README.zh.md
@@ -10,7 +10,7 @@
`dsh ` 是 `dsh --profile ` 的简写,启动位于 `$DSH_HOME/profiles/` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
-组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
+组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 runtime resolution 安装到 Node 的解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。
`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。
@@ -50,7 +50,7 @@ dsh --profile web --dump-default-config
dsh --profile web --patch ./extra.yml --dump-config
```
-`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
+`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
## 启动诊断
@@ -118,4 +118,4 @@ dsh web --help
## 源码执行
-请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
+请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts
index 9b6d0618ea..9e40d01f70 100644
--- a/apps/cli/src/profile-boot.ts
+++ b/apps/cli/src/profile-boot.ts
@@ -18,9 +18,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import {
boot,
readProfilePatches,
- createProfileResolutionGeneration,
- healProfilesModuleFallback,
- healIsolatedProfileModuleFallback,
+ createRuntimeResolution,
initProfile,
installFailLoud,
loadOverlayPatches,
@@ -31,8 +29,7 @@ import {
resolveProfileDir,
type ProfileContext,
type Profile,
- type ProfileResolutionGeneration,
- type ProfileResolutionMode,
+ type RuntimeResolution,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -176,8 +173,8 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
/** One profile's patch layers, in application order. */
interface ComposedProfile {
profile: Profile
- /** Immutable package fallback selected before any plugin imports. */
- resolution: ProfileResolutionGeneration
+ /** Immutable runtime resolution computed before any plugin imports. */
+ resolution: RuntimeResolution
/** Command-line overlay contents, frozen for this invocation. */
overlays: PatchOptions[]
}
@@ -198,17 +195,13 @@ interface ComposedProfile {
async function composeProfile(
name: string,
patchFiles: readonly string[],
- resolutionMode: ProfileResolutionMode,
fromDefaultProfile?: string,
resolvedProfile?: ResolvedProfileRuntime,
): Promise {
const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
- if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
- const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
- ? await createProfileResolutionGeneration(resolutionOptions)
- : await healProfilesModuleFallback(resolutionOptions)
+ const resolution = await createRuntimeResolution(resolutionOptions)
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
return { profile, resolution, overlays }
}
@@ -237,8 +230,6 @@ export interface RunProfileOptions {
args: readonly string[]
/** Application-owned package runtime, scoped to plugin package operations. */
packageManager?: ProfileContext['packageManager']
- /** Module fallback backend; pkg executables always use runtime resolution. */
- resolutionMode?: ProfileResolutionMode
}
/**
@@ -258,8 +249,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
(message) => { process.stderr.write(`${NAME}: ${message}\n`) },
)
- const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
- const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
const app: { current?: Context } = {}
let disposal: Promise | undefined
const dispose = (): Promise => disposal ??= (async () => {
@@ -272,7 +261,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
})()
try {
const composed = await composeProfile(
- options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
+ options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,
)
const appReady = createAppReady()
const shutdown = createProcessShutdown(dispose)
@@ -308,9 +297,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// Before any config-tree entry mounts, so plugins resolve all launch-time
// environment values from the same immutable launch snapshot.
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
- await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
- generation: composed.resolution,
- behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
+ await hostCtx.plugin(PluginPackages, {
+ resolution: composed.resolution,
})
// The command line and bounded exit request are launcher facts available
// to every app plugin that injects the argument snapshot.
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
index 781396262a..1cd1c1636c 100644
--- a/apps/cli/tests/built-bin.e2e.ts
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -17,6 +17,7 @@ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { execa } from 'execa'
import * as yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
@@ -341,6 +342,8 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
+ testProfileResolution('lib')
+
it('requires a profile and rejects removed flags', async () => {
const bare = await runBuiltBin()
expect(bare.code).toBe(1)
@@ -984,7 +987,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
try {
await waitForFile(fixture.ready)
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
- expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true)
+ expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(false)
requestProfileShutdown(child, fixture)
expect((await child).exitCode).toBe(0)
} finally {
diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts
index dc6ee23ad9..dce2ecbce0 100644
--- a/apps/cli/tests/desktop-host.e2e.ts
+++ b/apps/cli/tests/desktop-host.e2e.ts
@@ -37,7 +37,8 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
process.send({ type: 'booting', packageManager: options.packageManager });
return new Promise((resolve, reject) => process.once('disconnect', () => {
if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
- resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
+ resolve({ ctx: { plugin: async () => {}, effect: () => {}, on: () => {},
+ connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
}));
}
@@ -46,7 +47,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
const pnpm = join(root, 'bundled-pnpm.mjs')
const nodeBin = join(root, 'bin')
- const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
+ const child = fork(entry, [root, root, root, pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
let stderr = ''
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
const exited = new Promise(resolve => child.once('exit', resolve))
diff --git a/apps/cli/tests/dsh-badge.expected.e2e.ts b/apps/cli/tests/dsh-badge.expected.e2e.ts
index 28e418d6fc..ca70622be2 100644
--- a/apps/cli/tests/dsh-badge.expected.e2e.ts
+++ b/apps/cli/tests/dsh-badge.expected.e2e.ts
@@ -25,10 +25,10 @@ describe('dsh badge assembled snapshot', () => {
configPath,
tsconfigPath,
})
- const disabledSnapshot = JSON.parse(disabled.stdout) as unknown
- const enabledSnapshot = JSON.parse(
+ const disabledSnapshot: unknown = JSON.parse(disabled.stdout)
+ const enabledSnapshot: unknown = JSON.parse(
enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'),
- ) as unknown
+ )
expect(disabled.stderr).toBe('')
expect(enabled.stderr).toBe('')
diff --git a/apps/cli/tests/fixtures/source-tool-driver.ts b/apps/cli/tests/fixtures/source-tool-driver.ts
new file mode 100644
index 0000000000..c448f4320d
--- /dev/null
+++ b/apps/cli/tests/fixtures/source-tool-driver.ts
@@ -0,0 +1,71 @@
+/** Source-profile tool-dispatch evidence from the real Agent and Node module cache. */
+
+import { randomUUID } from 'node:crypto'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-cmdline'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+
+/** Observations from one isolated Agent's run through the shipped headless profile. */
+export interface SourceToolEvidence {
+ execArgv: string[]
+ modules: string[]
+ errors: string[]
+ events: SessionEvent<'tool/call' | 'tool/result' | 'turn/end'>[]
+}
+
+/** Services needed to drive the Agent and inspect loaded modules. */
+export const inject = ['agents', 'loader']
+
+/**
+ * Drive one mock-model turn after application readiness and dispose it before requesting exit.
+ * @param ctx - context in the source-launched headless Loader tree.
+ * @param config - isolated working directory for the Agent.
+ */
+export function apply(ctx: Context, config: { cwd: string }): void {
+ const ready = ctx.get('appReady')
+ const exit = ctx.get('appExit')
+ if (ready === undefined || exit === undefined) throw new Error('Source tool driver requires appReady and appExit')
+ const events: SourceToolEvidence['events'] = []
+ const errors: string[] = []
+ ctx.on('session/event', (_session, event) => {
+ if (event.type === 'tool/call' || event.type === 'tool/result' || event.type === 'turn/end') events.push(event)
+ }, { global: true })
+ ctx.on('agent/error', ({ error }) => { errors.push(error instanceof Error ? error.message : String(error)) }, { global: true })
+
+ const run = async (): Promise => {
+ const handle = await ctx.agents.create({
+ sessionId: SessionId(randomUUID()),
+ meta: { cwd: config.cwd },
+ agentOptions: { provider: 'cli-mock', model: 'cli-mock' },
+ })
+ try {
+ handle.agent.followup(createUserMessage({
+ source: { kind: 'user' },
+ content: [{ type: 'text', text: 'Run the shell smoke command once.' }],
+ }))
+ await handle.agent.whenIdle()
+ const internal = ctx.loader.internal
+ if (internal === undefined) throw new Error('Source tool driver requires Node module-cache introspection')
+ const evidence: SourceToolEvidence = {
+ execArgv: process.execArgv,
+ modules: [...internal.loadCache.keys()].filter(url => /\/packages\/core\/(?:tools|agent-loop)\//.test(url)),
+ errors,
+ events,
+ }
+ process.stdout.write(`DSH_SOURCE_TOOL_RESULT ${JSON.stringify(evidence)}\n`)
+ } finally {
+ await handle.dispose()
+ }
+ exit(errors.length === 0 ? 0 : 1)
+ }
+
+ ctx.effect(() => ready.onReady(() => {
+ void run().catch((error: unknown) => {
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`)
+ exit(1)
+ })
+ }), 'source tool driver')
+}
diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts
index b438799e38..347389996f 100644
--- a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts
+++ b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts
@@ -43,12 +43,12 @@ class ControlSurfaceAdapter extends LlmAdapter {
}
override async * stream(options: GenerateOptions): AsyncIterable {
- const lastUserIndex = options.messages.findLastIndex(message => message.source.kind === 'user')
+ const lastUserIndex = options.messages.findLastIndex(message => message.role === 'user' && message.source?.kind === 'user')
const current = options.messages.slice(lastUserIndex)
const userText = current.flatMap(message => message.content)
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('')
- const hasToolResult = current.some(message => message.content.some(block => block.type === 'tool-result'))
+ const hasToolResult = current.some(message => message.role === 'tool')
if (!hasToolResult) {
const callId = ToolCallId(userText.includes('cancel') ? 'control-cancel-add' : 'control-add')
yield { type: 'block-start', index: 0, blockType: 'reasoning' }
diff --git a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
index 852e58453c..e2dd1e01b1 100644
--- a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
+++ b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
@@ -87,7 +87,7 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
const log = result.sessionLogs[0]
if (log === undefined) throw new Error('goal snapshot did not persist its session')
const records = parseJsonl(log.content)
- const events = records.slice(1) as unknown as SessionEvent[]
+ const events = records.slice(1) as SessionEvent[]
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['create_goal', 'get_goal'])
const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
@@ -136,7 +136,7 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
const log = result.sessionLogs[0]
if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session')
const records = parseJsonl(log.content)
- const events = records.slice(1) as unknown as SessionEvent[]
+ const events = records.slice(1) as SessionEvent[]
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['create_goal', 'update_goal'])
expect(foldGoal(events)).toMatchObject({
@@ -150,7 +150,7 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
// The wrap-up instruction is one plugin-sourced context injected after the
// terminal tool result, and the model still answers inside the same turn.
const wrapups = events.filter(event => event.type === 'user/message'
- && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal')
+ && event.data.source.kind === 'tool-goal')
expect(wrapups).toHaveLength(1)
const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0]
expect(JSON.stringify(wrapupText)).toContain('')
diff --git a/apps/cli/tests/profiles/headless/real-model.patch.yml b/apps/cli/tests/profiles/headless/real-model.patch.yml
index e0471e17d5..b0c161b3c6 100644
--- a/apps/cli/tests/profiles/headless/real-model.patch.yml
+++ b/apps/cli/tests/profiles/headless/real-model.patch.yml
@@ -13,8 +13,10 @@
models:
- id: deepseek-v4-pro
contextWindow: 128000
+ maxTokens: 8192
- id: deepseek-v4-flash
contextWindow: 128000
+ maxTokens: 8192
- id: agent-loop
config:
diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
index 545d57577c..bf2d550bcc 100644
--- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
@@ -35,9 +35,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
// Reasoning tokens require a larger generation cap than the retained checkpoint.
ctx = await codingHarness(workdir, {
personaPrefix: SYSTEM_PROMPT,
- modelContextWindow: 8000,
+ // The explicit output cap leaves an 8,000-token message budget.
+ modelContextWindow: 15_000,
+ modelMaxTokens: 7_000,
compact: {
thresholdRatio: 0.5,
+ headroomTokens: 4_000,
retainTokens: 400,
summarizationProvider: '',
summarizationModel: '',
@@ -78,7 +81,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
const summaries = events.filter(e => e.type === 'compaction/summary')
expect(summaries.length, JSON.stringify(ends.map(event => event.data.error))).toBeGreaterThan(0)
const replaceNode = events.find((e) => {
- const se = e as unknown as { type: string; surfaceOp?: unknown }
+ const se = e as { type: string; surfaceOp?: unknown }
return se.type === 'user/message' && typeof se.surfaceOp === 'object' && se.surfaceOp !== null
})
expect(replaceNode).toBeDefined()
@@ -89,7 +92,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0)
expect(events.some(event => event.type === 'tool/result'
&& summaryData.shadowedSeqs.includes(event.seq)
- && event.data.message.content[0].content.some(block => block.type === 'text'
+ && event.data.message.content.some(block => block.type === 'text'
&& block.text.includes('This is file number')))).toBe(true)
// The conversation survived compaction: the agent produced a final answer
diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl
index 8d8ea28581..36fe8fd2d4 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl
@@ -2,28 +2,28 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_probe"},"toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":22,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_create"},"toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":27,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_get"},"toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl
index cf4f395dd4..0f7431a36e 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl
@@ -2,13 +2,13 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl
index b285bfab73..e8f421ae70 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl
@@ -2,13 +2,13 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl
index cb83ea205a..bce6fb173d 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl
@@ -2,13 +2,13 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":15,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":16,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl
index ac7306cfdd..a359220fbb 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl
@@ -5,6 +5,7 @@
{"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
+{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"descriptorless child"}}
{"type":"session/end-seed","data":{}}
{"type":"permission/preset","data":{"preset":"workspace-write"}}
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
@@ -21,9 +22,9 @@
{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}}
-{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"step/start","data":{"turn":2,"step":2}}
-{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The stored subagent is unreadable. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":2}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/replay.override.json b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/replay.override.json
index 2b9facf71f..6d66bcf03d 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/replay.override.json
+++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/replay.override.json
@@ -1 +1 @@
-[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The stored subagent is unreadable. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The stored subagent is unreadable. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]
+[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The catalogued subagent is ready. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The catalogued subagent is ready. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]
diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
index b027378207..3c8e6615eb 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
@@ -2,18 +2,18 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[8],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"text","text":"started subagent {{sessionId}}"}],"role":"tool","toolCallId":"start-child","isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}
diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs
index 8319ed9539..3e2da6553f 100644
--- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs
+++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs
@@ -28,11 +28,9 @@ function hasTaskAction(messages, action) {
}
function latestToolText(messages) {
- const message = messages.findLast(candidate => candidate.content.some(block => block.type === 'tool-result'))
+ const message = messages.findLast(candidate => candidate.role === 'tool')
if (message === undefined) return ''
- return message.content.flatMap(block => block.type === 'tool-result'
- ? block.content.filter(item => item.type === 'text').map(item => item.text)
- : []).join('\n')
+ return message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
}
function toolChunks(specs) {
diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
index 6816c60f61..bbacf9dc15 100644
--- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
const results = events.filter(event => event.type === 'tool/result')
const resultTexts = results.flatMap(event =>
- event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text))
+ event.data.message.content.filter(block => block.type === 'text').map(block => block.text))
expect(resultTexts.some(text => text.includes('e2e-ok'))).toBe(true)
expect(finalText(events)).toContain('e2e-ok')
diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts
index 05d4a9e814..9c17fa27f4 100644
--- a/apps/cli/tests/profiles/headless/tests/harness.ts
+++ b/apps/cli/tests/profiles/headless/tests/harness.ts
@@ -49,8 +49,18 @@ export interface CodingHarnessOptions {
* compaction plugin (the default suites run without it).
*/
compact?: BasicCompactionConfig
- /** Test-only context capacity advertised for `deepseek-v4-flash`. */
+ /**
+ * Test-only context capacity advertised for `deepseek-v4-flash`. Automatic
+ * pressure scales the message budget left after the request's reserved
+ * output tokens, so this capacity must exceed the advertised output cap.
+ */
modelContextWindow?: number
+ /**
+ * Test-only per-request output cap advertised for `deepseek-v4-flash`, which
+ * the adapter forwards as `max_tokens` and pressure excludes from the
+ * capacity above.
+ */
+ modelMaxTokens?: number
}
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise {
@@ -60,8 +70,12 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, {
- ...options.modelContextWindow === undefined ? {} : {
- models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }],
+ ...options.modelContextWindow === undefined && options.modelMaxTokens === undefined ? {} : {
+ models: [{
+ id: 'deepseek-v4-flash',
+ ...options.modelContextWindow === undefined ? {} : { contextWindow: options.modelContextWindow },
+ ...options.modelMaxTokens === undefined ? {} : { maxTokens: options.modelMaxTokens },
+ }],
},
})
await ctx.plugin(LocalSubprocessRuntime)
diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
index 62bccb3b29..4724981169 100644
--- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
@@ -234,10 +234,12 @@ async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions'))
describe('headless stream-json snapshots', () => {
- it('runs one task through the product headless profile command', async () => {
+ it.each(['src', 'lib'] as const)('runs one task through the product headless profile command (%s)', async (mode) => {
const task = 'Prove the product headless profile path with one real tool round trip.'
const result = await runLoaderSmoke({
label: 'product headless profile snapshot',
+ mode,
+ sourceImport: 'tsx/esm',
tempDirPrefix: 'headless-snapshot-profile-',
binScript: dshBinScript,
configPath: headlessOverlayPath,
@@ -925,8 +927,7 @@ describe('headless stream-json snapshots', () => {
})
const probeData = probeResult?.data as JsonObject | undefined
const probeMessage = probeData?.message as JsonObject | undefined
- const probeContent = probeMessage?.content as JsonObject[] | undefined
- expect(probeContent?.[0]?.isError).toBe(true)
+ expect(probeMessage?.isError).toBe(true)
expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
const goalChanges = records.filter(record => record.type === 'goal/change')
expect(goalChanges).toHaveLength(1)
diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts
new file mode 100644
index 0000000000..80601c27c6
--- /dev/null
+++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts
@@ -0,0 +1,254 @@
+/** Real CLI profile imports in source and built launches, including npm-link dependency layouts. */
+
+import { realpathSync } from 'node:fs'
+import { mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { resolveExampleLaunch, type ExampleMode } from '@deepseek-ai/dsh-loader-smoke'
+import { execa } from 'execa'
+import { expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const processTimeoutMs = 75_000
+const bundleName = 'profile-resolution-bundle'
+const bridgeName = 'profile-resolution-bridge'
+const leafName = 'profile-resolution-leaf'
+const pluginName = 'profile-resolution-plugin'
+const externalName = 'profile-resolution-external'
+const externalLeafName = 'profile-resolution-external-leaf'
+const marker = 'DSH_PROFILE_RESOLUTION '
+
+interface ResolutionEvidence {
+ execArgv: string[]
+ esm: { version: string; url: string }
+ cjs: { version: string; filename: string }
+ sameEsmLeaf: boolean
+ sameCjsLeaf: boolean
+ externalEsm: { version: string; url: string; leaf: ResolutionEvidence['esm'] }
+ externalCjs: { version: string; filename: string; leaf: ResolutionEvidence['cjs'] }
+ sameEsmExternal: boolean
+ sameCjsExternal: boolean
+ sameEsmExternalLeaf: boolean
+ sameCjsExternalLeaf: boolean
+ externalManaged: boolean
+ toolsInstance: boolean
+ scheduler: boolean
+ modules: string[]
+}
+
+async function writePackage(dir: string, manifest: Record, files: Record): Promise {
+ await mkdir(dir, { recursive: true })
+ await writeFile(join(dir, 'package.json'), JSON.stringify(manifest) + '\n')
+ for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content + '\n')
+}
+
+/**
+ * Register the same runtime-resolution assertions in the source-only and built-bin lanes.
+ * @param mode - source uses the CLI's ESM-only tsx hook; lib requires built workspace exports.
+ */
+export function testProfileResolution(mode: ExampleMode): void {
+ it.each(['installed', 'npm-link'] as const)(`resolves a %s profile dependency graph in ${mode} mode`, {
+ timeout: processTimeoutMs + 15_000, retry: 0,
+ }, async (layout) => {
+ // The child reports loaded module paths in the form DSH_HOME was given; hand it the native realpath so
+ // Windows 8.3 tmpdir names and macOS /var symlinks match the expectations computed below.
+ const root = realpathSync.native(await mkdtemp(join(tmpdir(), 'dsh-profile-resolution-')))
+ const links: string[] = []
+ try {
+ const home = join(root, 'home')
+ const profileDir = join(home, 'profiles', 'headless')
+ const installedBundle = join(profileDir, 'node_modules', bundleName)
+ const bundleDir = layout === 'npm-link' ? join(root, 'work', bundleName) : installedBundle
+ const installedBridge = join(bundleDir, 'node_modules', bridgeName)
+ const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge
+ const logicalLeaf = join(bundleDir, 'node_modules', leafName)
+ const realLeaf = join(root, 'dependencies', 'node_modules', leafName)
+ const exports = { import: './index.mjs', require: './index.cjs' }
+ await writePackage(bundleDir, {
+ name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' },
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
+ }, { 'cordis.patch.yml': '[]' })
+ await writePackage(bridgeDir, {
+ name: bridgeName, version: '1.0.0', exports, dependencies: { [leafName]: '*' },
+ }, {
+ 'index.mjs': `export { leaf } from '${leafName}'`,
+ 'index.cjs': `module.exports = require('${leafName}')`,
+ })
+ for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) {
+ await writePackage(dir, { name: leafName, version, exports }, {
+ 'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
+ 'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
+ })
+ }
+ if (layout === 'npm-link') {
+ const globalBundle = join(root, 'npm-global', 'node_modules', bundleName)
+ for (const [target, link] of [[bundleDir, globalBundle], [globalBundle, installedBundle], [bridgeDir, installedBridge]] as const) {
+ await mkdir(dirname(link), { recursive: true })
+ await symlink(target, link, 'junction')
+ links.push(link)
+ }
+ }
+
+ const sharedModules = join(home, 'profiles', 'node_modules')
+ const pluginDir = join(profileDir, 'node_modules', pluginName)
+ const sharedExternal = join(sharedModules, externalName)
+ const externalDir = layout === 'npm-link' ? join(root, 'external', externalName) : sharedExternal
+ const externalLeaf = join(externalDir, 'node_modules', externalLeafName)
+ const sharedLeaf = join(sharedModules, externalLeafName)
+ const ancestorExternal = join(home, 'node_modules', externalName)
+ const ancestorLeaf = join(home, 'node_modules', externalLeafName)
+ await writePackage(pluginDir, {
+ name: pluginName, version: '1.0.0', exports, dependencies: { [externalName]: '*' },
+ }, {
+ 'index.mjs': `export { external } from '${externalName}'\nexport function apply() {}`,
+ 'index.cjs': `module.exports = require('${externalName}')`,
+ })
+ for (const [dir, version] of [[externalDir, '3.0.0'], [ancestorExternal, '9.0.0']] as const) {
+ await writePackage(dir, { name: externalName, version, exports, dependencies: { [externalLeafName]: '*' } }, {
+ 'index.mjs': `import { leaf } from '${externalLeafName}'\nexport const external = { version: '${version}', url: import.meta.url, leaf }`,
+ 'index.cjs': `exports.external = { version: '${version}', filename: __filename, leaf: require('${externalLeafName}').leaf }`,
+ })
+ }
+ for (const [dir, version] of [[externalLeaf, '4.0.0'], [sharedLeaf, '8.0.0'], [ancestorLeaf, '9.0.0']] as const) {
+ await writePackage(dir, { name: externalLeafName, version, exports }, {
+ 'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
+ 'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
+ })
+ }
+ const staleTools = join(root, 'stale-tools')
+ const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools')
+ await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, {
+ 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')",
+ 'index.cjs': "throw new Error('STALE_DSH_TOOLS')",
+ })
+ for (const [target, link] of [
+ [staleTools, staleToolsLink],
+ ...layout === 'npm-link' ? [[externalDir, sharedExternal]] as const : [],
+ ] as const) {
+ await mkdir(dirname(link), { recursive: true })
+ await symlink(target, link, 'junction')
+ links.push(link)
+ }
+
+ // The probe imports from the profile, where the bundle's transitive dependencies need fallback.
+ await writePackage(profileDir, {
+ name: 'resolution-profile', private: true, dependencies: { [bundleName]: '*', [pluginName]: '*' },
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', bundleName] } },
+ }, {
+ 'probe.mjs': [
+ "import { createRequire } from 'node:module'",
+ "import { getEnvironmentData } from 'node:worker_threads'",
+ "import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
+ `import { leaf } from '${leafName}'`,
+ `import { leaf as bridgeLeaf } from '${bridgeName}'`,
+ `import { external } from '${externalName}'`,
+ `import { external as pluginExternal } from '${pluginName}'`,
+ `import { leaf as externalLeaf } from ${JSON.stringify(pathToFileURL(join(externalLeaf, 'index.mjs')).href)}`,
+ 'const require = createRequire(import.meta.url)',
+ "export const inject = ['tools', 'agentLoop', 'loader']",
+ 'export function apply(ctx) {',
+ " const ready = ctx.get('appReady')",
+ " const exit = ctx.get('appExit')",
+ ' ctx.effect(() => ready.onReady(() => {',
+ ` const cjs = require('${leafName}').leaf`,
+ ` const externalCjs = require('${externalName}').external`,
+ " const entries = getEnvironmentData('@deepseek-ai/dsh-app-boot/profile-resolution').resolution.entries",
+ ' const evidence = {',
+ ' execArgv: process.execArgv, esm: leaf, cjs,',
+ ' sameEsmLeaf: leaf === bridgeLeaf,',
+ ` sameCjsLeaf: cjs === require('${bridgeName}').leaf,`,
+ ' externalEsm: external, externalCjs,',
+ ' sameEsmExternal: external === pluginExternal,',
+ ` sameCjsExternal: externalCjs === require('${pluginName}').external,`,
+ ' sameEsmExternalLeaf: external.leaf === externalLeaf,',
+ ` sameCjsExternalLeaf: externalCjs.leaf === require(${JSON.stringify(join(externalLeaf, 'index.cjs'))}).leaf,`,
+ ` externalManaged: entries.some(entry => [${JSON.stringify(externalName)}, ${JSON.stringify(externalLeafName)}].includes(entry.name)),`,
+ ' toolsInstance: ctx.tools instanceof Tools,',
+ " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
+ ' modules: [...ctx.loader.internal.loadCache.keys()]',
+ ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
+ ' }',
+ ` process.stdout.write('${marker}' + JSON.stringify(evidence) + '\\n')`,
+ ' exit(0)',
+ " }), 'profile resolution probe')",
+ '}',
+ ].join('\n'),
+ 'cordis.patch.yml': JSON.stringify([
+ { id: 'headless-startup', disabled: true },
+ { id: 'headless-runner', disabled: true },
+ { id: 'llm-deepseek', disabled: true },
+ { insert: [
+ { id: 'external-plugin', name: pluginName },
+ { id: 'resolution-probe', name: join(profileDir, 'probe.mjs') },
+ ] },
+ ]),
+ })
+ const packageDirs = [
+ bundleDir, bridgeDir, logicalLeaf, realLeaf, pluginDir, externalDir, externalLeaf,
+ sharedLeaf, ancestorExternal, ancestorLeaf, staleTools, profileDir,
+ ]
+ const packageFiles = (await Promise.all(packageDirs.map(async dir =>
+ (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isFile()).map(entry => join(dir, entry.name)),
+ ))).flat()
+ const contents = await Promise.all(packageFiles.map(async path => [path, await readFile(path)] as const))
+ const linkTargets = await Promise.all(links.map(async path => [path, await readlink(path)] as const))
+ const launch = resolveExampleLaunch({
+ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
+ mode, sourceImport: 'tsx/esm', tsconfigPath: join(repoRoot, 'tsconfig.json'),
+ configArgs: ['--profile', 'headless'],
+ env: {
+ DSH_HOME: home, DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1',
+ NODE_OPTIONS: undefined, TSX_TSCONFIG_PATH: undefined,
+ },
+ })
+ const result = await execa(launch.command, launch.args, {
+ cwd: repoRoot, env: launch.env, input: '', timeout: processTimeoutMs,
+ killSignal: 'SIGKILL', reject: false,
+ })
+ const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
+ expect(result.timedOut, diagnostic).toBe(false)
+ expect(result.signal, diagnostic).toBeUndefined()
+ expect(result.exitCode, diagnostic).toBe(0)
+ const records = result.stdout.split('\n').filter(line => line.startsWith(marker))
+ expect(records, diagnostic).toHaveLength(1)
+ const evidence = JSON.parse(records[0]!.slice(marker.length)) as ResolutionEvidence
+ const selectedLeaf = layout === 'npm-link' ? realLeaf : logicalLeaf
+ const version = layout === 'npm-link' ? '2.0.0' : '1.0.0'
+ expect(evidence.esm).toEqual({ version, url: pathToFileURL(realpathSync.native(join(selectedLeaf, 'index.mjs'))).href })
+ expect(evidence.cjs).toEqual({ version, filename: realpathSync.native(join(selectedLeaf, 'index.cjs')) })
+ expect(evidence.sameEsmLeaf).toBe(true)
+ expect(evidence.sameCjsLeaf).toBe(true)
+ expect(evidence.externalEsm).toEqual({
+ version: '3.0.0', url: pathToFileURL(realpathSync.native(join(externalDir, 'index.mjs'))).href,
+ leaf: { version: '4.0.0', url: pathToFileURL(realpathSync.native(join(externalLeaf, 'index.mjs'))).href },
+ })
+ expect(evidence.externalCjs).toEqual({
+ version: '3.0.0', filename: realpathSync.native(join(externalDir, 'index.cjs')),
+ leaf: { version: '4.0.0', filename: realpathSync.native(join(externalLeaf, 'index.cjs')) },
+ })
+ expect(evidence.sameEsmExternal).toBe(true)
+ expect(evidence.sameCjsExternal).toBe(true)
+ expect(evidence.sameEsmExternalLeaf).toBe(true)
+ expect(evidence.sameCjsExternalLeaf).toBe(true)
+ expect(evidence.externalManaged).toBe(false)
+ expect(evidence.toolsInstance).toBe(true)
+ expect(evidence.scheduler).toBe(true)
+ expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
+ for (const name of ['tools', 'agent-loop']) {
+ const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
+ const other = mode === 'src' ? 'lib' : 'src'
+ expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${name}/${selected}`))).toHaveLength(1)
+ expect(evidence.modules.filter(url => url.includes(`/packages/core/${name}/${other}/`))).toEqual([])
+ }
+ for (const [path, content] of contents) expect(await readFile(path), path).toEqual(content)
+ for (const [path, target] of linkTargets) expect(await readlink(path), path).toBe(target)
+ } finally {
+ try {
+ for (const link of links.reverse()) await unlink(link)
+ } finally {
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
+ }
+ }
+ })
+}
diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
index c5d679484e..e2815248f0 100644
--- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
@@ -52,7 +52,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise {
+ it('dispatches a source-profile shell call to a result, including sandbox-unavailable hosts, without mixing tools src/lib', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-source-tool-'))
+ try {
+ const cwd = join(root, 'workspace')
+ await mkdir(cwd)
+ const patch = join(root, 'source-tool.patch.yml')
+ await writeFile(patch, JSON.stringify([
+ { id: 'headless-startup', disabled: true },
+ { id: 'headless-runner', disabled: true },
+ { id: 'llm-deepseek', disabled: true },
+ { id: 'agent-default-model', config: { provider: 'cli-mock', model: 'cli-mock' } },
+ { insert: [
+ {
+ id: 'cli-mock-llm',
+ name: join(repoRoot, 'packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts'),
+ },
+ {
+ id: 'source-tool-driver',
+ name: fileURLToPath(new URL('../../../fixtures/source-tool-driver.ts', import.meta.url)),
+ config: { cwd },
+ },
+ ] },
+ ]))
+ // Source modules and driver stay unbuilt; native and generated runtime artifacts are required.
+ const result = await execa(process.execPath, [
+ '--import', 'tsx/esm', dshSourceBin, '--profile', 'headless', '--patch', patch,
+ ], {
+ cwd: repoRoot,
+ env: {
+ ...clearedProxyEnv(),
+ DSH_HOME: join(root, 'home'),
+ DSH_AGENTS_HOME: join(root, 'agents'),
+ DSH_TELEMETRY_DISABLED: '1',
+ DSH_TOOLS_MODE: 'native',
+ DSH_CLI_MOCK_FAILURE: '0',
+ },
+ input: '',
+ timeout: sourceToolTimeoutMs - 15_000,
+ killSignal: 'SIGKILL',
+ reject: false,
+ })
+ const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
+ expect(result.timedOut, diagnostic).toBe(false)
+ expect(result.signal, diagnostic).toBeUndefined()
+ expect(result.exitCode, diagnostic).toBe(0)
+ const records = result.stdout.split('\n').filter(line => line.startsWith('DSH_SOURCE_TOOL_RESULT '))
+ expect(records, diagnostic).toHaveLength(1)
+ const evidence = JSON.parse(records[0]!.slice('DSH_SOURCE_TOOL_RESULT '.length)) as SourceToolEvidence
+ expect(evidence.execArgv).toEqual(['--import', 'tsx/esm'])
+ expect(evidence.errors).toEqual([])
+ for (const pkg of ['tools', 'agent-loop']) {
+ expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${pkg}/src/index.ts`))).toHaveLength(1)
+ expect(evidence.modules.filter(url => url.includes(`/packages/core/${pkg}/lib/`))).toEqual([])
+ }
+ const calls = evidence.events.filter(event => event.type === 'tool/call')
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.data).toMatchObject({
+ callId: 'cli-smoke-call', name: process.platform === 'win32' ? 'pwsh' : 'bash',
+ })
+ const results = evidence.events.filter(event => event.type === 'tool/result')
+ expect(results).toHaveLength(1)
+ const toolResult = results[0]!.data
+ const message = toolResult.message
+ expect(message).toMatchObject({ role: 'tool', toolCallId: 'cli-smoke-call' })
+ if (toolResult.error !== undefined) {
+ expect(toolResult.error).toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
+ expect(message.isError).toBe(true)
+ } else {
+ expect(message.isError).not.toBe(true)
+ expect(message.content.filter(part => part.type === 'text').map(part => part.text).join(''))
+ .toContain('CLI_TOOL_ROUND_TRIP')
+ }
+ expect(evidence.events.filter(event => event.type === 'turn/end').map(event => event.data.reason))
+ .toEqual([{ kind: 'completed' }])
+ } finally {
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
+ }
+ }, sourceToolTimeoutMs)
+})
diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
index 7cdb9a2e36..cec8642309 100644
--- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
@@ -1,7 +1,6 @@
/**
- * Assembled-app regression: a persisted `origin: 'subagent'` child whose log
- * carries no descriptor event is surfaced by `list_agents` as a
- * `[diagnostic: corrupt]` row instead of being silently dropped.
+ * Assembled-app regression: a parent-owned catalog event remains sufficient
+ * for direct discovery when the persisted child has no descriptor event.
*/
import { readFile, readdir, writeFile } from 'node:fs/promises'
@@ -42,11 +41,10 @@ async function expectSession(actual: string, expectedPath: string): Promise {
+async function seedCataloguedDescriptorlessChild(root: string, cwd: string): Promise {
const ctx = new Context()
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
const parentMeta: SessionHeader = {
@@ -62,12 +60,24 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise
{ type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
{
type: 'system/message', seq: SessionSeq(2), time: 12,
- data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+ data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'system-prompt' } }) },
surfaceOp: 'append',
},
{ type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'step/end', seq: SessionSeq(4), time: 14, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: SessionSeq(5), time: 15, data: { turn: 1, reason: { kind: 'completed' } } },
+ {
+ type: 'subagent/catalog',
+ seq: SessionSeq(6),
+ time: 16,
+ data: {
+ version: 0,
+ childId,
+ childCreatedAt: 2,
+ mode: 'continuable',
+ label: 'descriptorless child',
+ },
+ },
]
const childMeta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
@@ -95,8 +105,8 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise
}
}
-describe('descriptor-less cold child diagnostic snapshot', () => {
- it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => {
+describe('parent-owned cold child catalog snapshot', () => {
+ it('discovers a catalogued child without reading its descriptor through the assembled headless app', async () => {
let cwd = ''
const result = await runLoaderSmoke({
label: 'subagent diagnostic headless stream-json snapshot',
@@ -112,7 +122,7 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
},
prepare: async (runCwd) => {
cwd = runCwd
- await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd)
+ await seedCataloguedDescriptorlessChild(join(runCwd, '.sessions'), runCwd)
},
inspect: async (runCwd) => {
const sessionsDir = join(runCwd, '.sessions')
@@ -121,9 +131,10 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"'))
if (parent === undefined) throw new Error('missing persisted parent log')
- // THE model-visible fact: the descriptor-less child is reported, not
- // silently dropped, and its reason is the corrupt classification.
- expect(parent).toContain(`${childId} [diagnostic: corrupt]`)
+ // THE model-visible fact: the parent catalog supplies discovery even
+ // though consulting the descriptor-less child would classify it corrupt.
+ expect(parent).toContain(`${childId} [inactive] — descriptorless child`)
+ expect(parent).not.toContain('[diagnostic: corrupt]')
const context: NormalizeContext = { sessionIds: [parentId, childId], cwd }
const normalizedParent = normalizeSessionSnapshot(parent, context)
@@ -139,7 +150,7 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
expect(records.at(-1)).toMatchObject({
type: 'result',
sessionId: parentId,
- output: 'The stored subagent is unreadable. PARENT_DONE',
+ output: 'The catalogued subagent is ready. PARENT_DONE',
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
index 5317b3f347..4b5970cd9f 100644
--- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
@@ -21,7 +21,8 @@ import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url))
const replayOverride = join(fixtureDir, 'replay.override.json')
-// Native replay preserves the V0 script's model outputs without migrating its pre-step user event.
+// The released V3 child fixture replays through the V3-to-V4 migration, so its
+// tool results lift and its plugin sources move onto producer kinds.
const childReplay = join(fixtureDir, 'child.replay.v3.jsonl')
const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
const childExpected = join(fixtureDir, 'child.expected.jsonl')
@@ -59,7 +60,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise {
{ type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
{
type: 'system/message', seq: SessionSeq(2), time: 12,
- data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+ data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'system-prompt' } }) },
surfaceOp: 'append',
},
{ type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
@@ -140,11 +141,10 @@ describe('parent-only override inheritance snapshot', () => {
const runtimeContexts = (content: string): string[] => content.trimEnd().split('\n').flatMap((line) => {
const record = JSON.parse(line) as {
type?: string
- data?: { source?: { kind?: string; plugin?: string }; content?: Array<{ type?: string; text?: unknown }> }
+ data?: { source?: { kind?: string }; content?: Array<{ type?: string; text?: unknown }> }
}
if (record.type !== 'user/message'
- || record.data?.source?.kind !== 'plugin'
- || record.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
+ || record.data?.source?.kind !== 'runtime-context') return []
return record.data.content?.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []) ?? []
})
const policyContexts = [...runtimeContexts(parent), ...runtimeContexts(child)]
diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
index cb4fdea338..b873ad7d77 100644
--- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
@@ -90,7 +90,7 @@ async function seedVisibleBaseline(
{ type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
{
type: 'system/message', seq: SessionSeq(2), time: 12,
- data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+ data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'system-prompt' } }) },
surfaceOp: 'append',
},
{
diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
index 3601745871..7019b87f71 100644
--- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
+++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
@@ -5,11 +5,15 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
-const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url))
const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
+const launch = resolveExampleLaunch({
+ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
+ mode: 'lib',
+})
const decompress = promisify(zstdDecompress)
/** Frame one text or tool response from the local Messages endpoint. */
@@ -86,16 +90,15 @@ describe('Python SDK dsh profile keyless smoke', () => {
if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
// The line-predicate protocol driving below is the genuinely custom part;
// execa owns spawn, the deadline, and exit settlement around it.
- const child = execa(process.execPath, [
- '--import',
- 'tsx/esm',
- binScript,
+ const child = execa(launch.command, [
+ ...launch.args,
'--profile',
'sdk',
...(editorEnabled ? ['--patch', editorPatch] : []),
], {
cwd: repoRoot,
env: {
+ ...launch.env,
DSH_HOME: join(root, '.dsh'),
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_DISABLED: '1',
@@ -236,16 +239,15 @@ describe('Python SDK dsh profile keyless smoke', () => {
await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve))
const address = modelServer.address()
if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
- const child = execa(process.execPath, [
- '--import',
- 'tsx/esm',
- binScript,
+ const child = execa(launch.command, [
+ ...launch.args,
'--profile',
'sdk-minimal',
...(editorEnabled ? ['--patch', editorPatch] : []),
], {
cwd: repoRoot,
env: {
+ ...launch.env,
DSH_HOME: join(root, '.dsh'),
DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.',
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
@@ -285,7 +287,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
const event = params?.event as Record | undefined
return params?.sessionId === 'minimal' && event?.type === 'turn/end'
}, () => stderr)
- expect(turnEnd).toMatchObject({
+ expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({
params: { event: { data: { reason: { kind: 'completed' } } } },
})
@@ -341,11 +343,11 @@ describe('Python SDK dsh profile keyless smoke', () => {
await mkdir(home)
if (blocked) await writeFile(join(home, 'logs'), 'blocked')
await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n')
- const child = execa(process.execPath, [
- '--import', 'tsx/esm', binScript, '--profile', 'sdk', '--patch', patch,
+ const child = execa(launch.command, [
+ ...launch.args, '--profile', 'sdk', '--patch', patch,
], {
cwd: repoRoot,
- env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
+ env: { ...launch.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
stdin: 'pipe',
stripFinalNewline: false,
timeout: 25_000,
@@ -378,15 +380,14 @@ describe('Python SDK dsh profile keyless smoke', () => {
it('rejects an invalid max-token success env value', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
try {
- const { exitCode, stdout, stderr } = await execa(process.execPath, [
- '--import',
- 'tsx/esm',
- binScript,
+ const { exitCode, stdout, stderr } = await execa(launch.command, [
+ ...launch.args,
'--profile',
'sdk',
], {
cwd: repoRoot,
env: {
+ ...launch.env,
DSH_HOME: join(root, '.dsh'),
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
index 0c5b4c26d4..b1f89c11d1 100644
--- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
@@ -138,7 +138,7 @@ function start(f: ReturnType, extra: string[] = []) {
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('__DSH_BOOT__')
- const bundlePath = /
+