Browse Source

Merge remote-tracking branch 'origin/master' into worktree/llm-deepseek-messages

# Conflicts:
#	apps/web/tsconfig.json
#	tsconfig.host.json
Yichen Jiang 1 tuần trước cách đây
mục cha
commit
2044d2906b
100 tập tin đã thay đổi với 1919 bổ sung277 xóa
  1. 6 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml
  2. 38 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md
  3. 38 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  6. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  7. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
  10. 1 1
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  12. 3 1
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  13. 3 1
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  14. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  15. 2 0
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  16. 2 0
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  17. 6 0
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
  18. 49 0
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
  19. 49 0
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml
  21. 10 12
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
  22. 10 12
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md
  23. 2 2
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
  24. 32 32
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  25. 32 32
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  26. 6 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  27. 39 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  28. 39 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  29. 6 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  30. 37 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  31. 37 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  32. 6 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
  33. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
  34. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
  35. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml
  36. 39 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
  37. 39 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md
  38. 2 2
      .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml
  39. 1 1
      .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
  40. 1 1
      .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md
  41. 2 2
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml
  42. 7 7
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
  43. 7 7
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md
  44. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  45. 2 0
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  46. 2 0
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  47. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  48. 13 10
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  49. 13 10
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  50. 6 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  51. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  52. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  53. 6 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml
  54. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md
  55. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md
  56. 2 2
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml
  57. 1 1
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
  58. 1 1
      .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
  59. 2 2
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
  60. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
  61. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
  62. 2 2
      .agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml
  63. 1 1
      .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
  64. 1 1
      .agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md
  65. 6 0
      .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml
  66. 35 0
      .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md
  67. 35 0
      .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md
  68. 6 0
      .agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
  69. 35 0
      .agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
  70. 35 0
      .agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
  71. 2 2
      .agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml
  72. 3 3
      .agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md
  73. 3 3
      .agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md
  74. 2 2
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
  75. 2 0
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
  76. 2 0
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
  77. 2 2
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
  78. 2 0
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
  79. 2 0
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
  80. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  81. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  82. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  83. 18 5
      .github/review-ownership/README.md
  84. 12 0
      .github/review-ownership/approval-policy.json
  85. 377 0
      .github/review-ownership/check-approval.mjs
  86. 317 0
      .github/review-ownership/check-approval.test.mjs
  87. 40 51
      .github/workflows/node-addon-system.yml
  88. 19 0
      .github/workflows/weighted-approval-review-event.yml
  89. 37 0
      .github/workflows/weighted-approval.yml
  90. 5 4
      THIRD_PARTY_NOTICES.md
  91. 1 0
      apps/cli/package.json
  92. 2 2
      apps/cli/reference/README.i18n.yaml
  93. 2 2
      apps/cli/reference/README.md
  94. 2 2
      apps/cli/reference/README.zh.md
  95. 0 2
      apps/cli/tests/built-bin.e2e.ts
  96. 1 0
      apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl
  97. 13 12
      apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
  98. 55 7
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  99. 18 17
      apps/cli/tests/web-agent-presets.e2e.ts
  100. 1 1
      apps/web/tests/agent-preset-authoring.e2e.ts

+ 6 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.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-08-web-explicit-file-delivery.md
+2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2
+2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f

+ 38 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md

@@ -0,0 +1,38 @@
+# Agent Note: Web delivers explicit file snapshots
+
+Status: implemented
+Archived: 2026-09-08
+
+English | [中文](2026-09-08-web-explicit-file-delivery.zh.md)
+
+## Problem
+
+Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports.
+
+## Decision
+
+The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated snapshot actions and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none.
+
+Download and native-open requests authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes.
+
+Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup.
+
+## Alternatives considered
+
+**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules.
+
+**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes.
+
+**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding.
+
+**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records.
+
+**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider.
+
+## Consequences
+
+The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations.
+
+The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references.
+
+Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion.

+ 38 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: Web 显式交付文件快照
+
+Status: implemented
+Archived: 2026-09-08
+
+[English](2026-09-08-web-explicit-file-delivery.md) | 中文
+
+## 问题
+
+工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证快照操作和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。
+
+下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。
+
+卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。
+
+## 已考虑的替代方案
+
+**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。
+
+**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。
+
+**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。
+
+**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。
+
+**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。
+
+## 影响
+
+实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。
+
+交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。
+
+定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -1381,6 +1381,9 @@
     "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58",
     "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15",
     "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23",
+    "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a",
+    "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093",
+    "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d",
     "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
     "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
     "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
-2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7
-2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c
+2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1
+2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md

@@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek`
 
 `dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
 
-The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
+The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O.
 
 The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 `dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
 
-插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中
+插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型
 
 适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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-22-slot-type-chain-implementation.md
-2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0
-2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b
+2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702
+2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee
 
 ## Decision
 
+Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md).
+
 One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
 
 ### 'root' is the only a-priori slot
@@ -25,7 +27,7 @@ ctx.slots.register({
   name: 'root',
   children: {
     'sidebar':      { kind: 'single', scope: 'root' },
-    'conversation': { kind: 'single', scope: 'session' },
+    'main':         { kind: 'keyed', scope: 'root' },
   },
   store: createLayoutStore,      // StoreHandle or factory (below)
   inject: injectFrame,           // business face (below)

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 ## 决策
 
+全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。
+
 一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
 
 ### 'root' 是唯一的先验 slot
@@ -25,7 +27,7 @@ ctx.slots.register({
   name: 'root',
   children: {
     'sidebar':      { kind: 'single', scope: 'root' },
-    'conversation': { kind: 'single', scope: 'session' },
+    'main':         { kind: 'keyed', scope: 'root' },
   },
   store: createLayoutStore,      // StoreHandle or factory (below)
   inject: injectFrame,           // business face (below)

+ 2 - 2
.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: eb5eb14f28a6459bd388caa2ea106ec01d1ebbe6
-2026-08-31-released-session-format-migrations.zh.md: b06bfac059541e9c397ff46fe7d169690c5b1213
+2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582
+2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -94,6 +94,8 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa
 
 Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter.
 
+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.
+
 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.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -94,6 +94,8 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder
 
 Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。
 
+发布的 `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。
+
 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。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.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-01-parent-owned-subagent-catalog.md
+2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280
+2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc

+ 49 - 0
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md

@@ -0,0 +1,49 @@
+# Agent Note: Parent-owned subagent catalog events
+
+Status: implemented
+
+English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md)
+
+## Problem
+
+Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions.
+
+The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children.
+
+## Decision
+
+The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog.
+
+Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol.
+
+The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor.
+
+The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points.
+
+The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats.
+
+Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding.
+
+Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim.
+
+Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity.
+
+Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged.
+
+## Alternatives considered
+
+**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety.
+
+**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting.
+
+**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map.
+
+**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log.
+
+**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol.
+
+## 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.
+
+Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs.

+ 49 - 0
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: parent 自有的 subagent 目录事件
+
+Status: implemented
+
+[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文
+
+## 问题
+
+直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。
+
+child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。
+
+## 决策
+
+parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。
+
+创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。
+
+child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。
+
+注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。
+
+工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。
+
+fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。
+
+Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。
+
+snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。
+
+即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。
+
+## 考虑过的替代方案
+
+**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。
+
+**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。
+
+**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。
+
+**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。
+
+**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。
+
+## 后果
+
+Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
+
+不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
-2026-09-05-client-resource-model.md: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8
-2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131
+2026-09-05-client-resource-model.md: e6ad8dae331d8a6392585f5dc8eb8fc52150bdc3
+2026-09-05-client-resource-model.zh.md: d716aa8a42f5c3037a329deabd572a56ab1b0537

+ 10 - 12
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md

@@ -8,7 +8,7 @@ English | [中文](2026-09-05-client-resource-model.zh.md)
 
 A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook.
 
-The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
+The tab record set the constraint. A tab must survive undo, redo, body remount, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. Browser-page reload resets the memory-only Sidebar state. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
 
 ## Decision
 
@@ -16,7 +16,7 @@ The tab record set the constraint. A tab must survive undo, redo, reload, and ho
 
 ### Addresses
 
-A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>`, with `session/<sessionId>` naming the session whose root resolves the file, or `dsh-resource://file/absolute/<absolute path>`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
+A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path>`, with `session/<sessionId>` naming the Session whose Host workspace resolves the relative or absolute path ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
 
 ### The service
 
@@ -30,32 +30,30 @@ interface Resources {
 interface ResourceProvider<P extends ResourceProtocol> {
   readonly protocol: P
   open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
-  reload?(address: string): void
 }
 
 interface ResourceSnapshot<Value> {
   readonly status: 'none' | 'loading' | 'live' | 'failed'
   readonly value: Value | undefined
   readonly failure: RemoteFailure | undefined
-  readonly reload: () => void
 }
 
 type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
 ```
 
-`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type.
+`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileStat`); the resources package re-exports the type.
 
 ### The hook
 
-`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or no `reload`.
+`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value.
 
 ### Frames
 
-A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md).
+A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads content itself through the [Workspace Files service](2026-09-05-workspace-files-service.md).
 
 ### Lifecycle
 
-One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
+One record exists per address. Holds start and stop observation, not the underlying file or Session. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
 
 The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model.
 
@@ -63,7 +61,7 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco
 
 **Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit.
 
-**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page.
+**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer chooses how much content to read and retain.
 
 **Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces.
 
@@ -71,17 +69,17 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco
 
 **A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed.
 
-**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address.
+**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is the framework-bound `useTabInfo` on the tab plus one client-wide `useResource` keyed by address.
 
 ## Consequences
 
-Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
+Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, body remount, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
 
 The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value.
 
 ## Testing
 
-`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
+`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, and failure frames beside the last value. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
 
 ## Deferred
 

+ 10 - 12
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。
 
-约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
+约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、正文重挂载与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。浏览器页面刷新会重置只在内存中的 Sidebar 状态。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
 
 ## Decision
 
@@ -16,7 +16,7 @@ Status: implemented
 
 ### 地址
 
-资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>`,`session/<sessionId>` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
+资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<path>`,`session/<sessionId>` 命名由其 Host 工作区解析相对或绝对路径的 Session([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
 
 ### 服务
 
@@ -30,32 +30,30 @@ interface Resources {
 interface ResourceProvider<P extends ResourceProtocol> {
   readonly protocol: P
   open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
-  reload?(address: string): void
 }
 
 interface ResourceSnapshot<Value> {
   readonly status: 'none' | 'loading' | 'live' | 'failed'
   readonly value: Value | undefined
   readonly failure: RemoteFailure | undefined
-  readonly reload: () => void
 }
 
 type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
 ```
 
-`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。
+`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileStat`);resources 包再导出这个类型。
 
 ### hook
 
-`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。
+`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。
 
 ### 帧
 
-提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。
+提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)读内容。
 
 ### 生命周期
 
-每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
+每个地址一条记录。持有只控制观察的启停,不控制底层文件或 Session 的生灭。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
 
 右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。
 
@@ -63,7 +61,7 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 **会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。
 
-**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因
+**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方选择读取并保留多少内容
 
 **以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。
 
@@ -71,17 +69,17 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 **手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。
 
-**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。
+**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上框架绑定的 `useTabInfo` 加一个按地址的客户端级 `useResource`。
 
 ## Consequences
 
-任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
+任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、正文重挂载或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
 
 代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。
 
 ## Testing
 
-`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
+`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
 
 ## Deferred
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.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-workspace-files-service.md
-2026-09-05-workspace-files-service.md: a95e083f8cea57b957a2c060fe1b7f2b76050153
-2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0
+2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85
+2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443

+ 32 - 32
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md

@@ -8,30 +8,32 @@ English | [中文](2026-09-05-workspace-files-service.zh.md)
 
 The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a `read` tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as `workspace-file.ts`, beside session lifecycle it had nothing to do with. It returned a whole file under one total byte cap, so a large log could not be looked at even in part and a binary could not be looked at at all; it had no `stat`, no listing, and no change signal, so a preview could not learn that the agent had rewritten the file without re-reading it; and its result named the file by a Host `url`, a spelling nothing on the Client used as an address.
 
-Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only and says so — so a web-facing read endpoint must own every fence itself, and the fences must survive a symlink that leaves the workspace, which a string-prefix test cannot see. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file.
+Two constraints frame the service. File reads through `ctx.fs` use the Session's composed filesystem backend, whose read authority may extend outside the workspace, while directory-tree and change-feed consumers are workspace-rooted. The service preserves the backend's read decisions for regular files while enforcing file-kind and bounded-buffer checks; `list` and `changes` retain workspace containment. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file.
 
 ## Decision
 
-`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and pages or windows content so that no method ever buffers a whole file. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code.
+`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. File methods resolve relative paths from the workspace root but inherit the Session filesystem backend's read authority; `list` and `changes` remain workspace-scoped. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns this split and its security consequences. Results name files by their absolute path in the filesystem's execution world, and content is bounded by page, byte window, or complete-file cap. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code.
 
 ### Package topology
 
-[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, authorization, paging, and change-feed decisions here remain in force. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry.
+[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, paging, and change-feed decisions here remain in force. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) supersedes the original workspace-containment choice for file methods. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry.
 
 | Face | Package | Files | Depends on |
 |---|---|---|---|
 | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` |
 | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
 
-`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
+`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
 
 ### The `workspaceFiles` Remote namespace
 
-Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The five signatures, as `src/index.ts` declares them:
+Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them:
 
 ```ts ignore-check
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -40,25 +42,26 @@ Every Host method takes the target `Agent` first, resolved by the Gateway from t
 - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
 - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream.
 - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`.
+- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics.
 - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered.
 - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation.
 
 ### Paths on the wire
 
-Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd.
+Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd.
 
 `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end.
 
-### The four gates
+### File checks and workspace containment
 
-Every `read`, `readBytes`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent.
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
 
 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
-2. **Containment.** The path resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way (the session's cwd, falling back to the policy's configured root). A `..` traversal or an absolute path outside the root is `outside-workspace`. A string-prefix comparison is never used: `resolve` realpaths, so a prefix test cannot see a link that leaves the root.
-3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so.
+2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
+3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
 
-After the gates the file methods `stat` the target once more, because the file may have gone or changed kind between the inspection and the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. The gate order has one visible consequence: an entry outside the root whose type already disqualifies it reports its kind, not its position.
+After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position.
 
 ### Failures
 
@@ -67,20 +70,20 @@ Each failure is one `RemoteError` code with typed details, declared beside the t
 | Code | When | Details |
 |---|---|---|
 | `workspace-file/not-found` | no entry at the path, or the file vanished after the gates | `{ path }` |
-| `workspace-file/outside-workspace` | the resolved target is not inside the workspace root | `{ path }` |
-| `workspace-file/too-large` | a page's text or a requested byte window exceeds `maxBytes` | `{ path, limit }` |
+| `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` |
+| `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` |
 | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` |
-| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
+| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
 | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
 | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` |
-| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address with no current Session | `{ address }` |
+| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` |
 | `gateway/bad-request` | an empty path, or an `offset`, `limit`, or `length` that is not an integer in range | `{}` |
 
 The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire.
 
 ### Configuration
 
-Three fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries. The file itself has no size cap: a caller pages or windows through it.
+Four fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries; `maxFileBytes` (default 33,554,432, 32 MiB) caps complete and related-file reads. Paged and windowed reads impose no whole-file size cap.
 
 ### The `readByteRange` seam in `dsh-fs`
 
@@ -96,27 +99,27 @@ It returns the bytes at `[offset, offset + length)`, shorter when the file ends
 
 ### The Client `file` provider
 
-The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The text-preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`.
+The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The Document Preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`.
 
-- **The value is metadata**, `WorkspaceFileResource { absolutePath, version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale.
-- **The address names the file; its scope selects the Session.** A `session` address's relative path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address reads through the current Session, failing with `workspace-file/unknown-workspace` when none is current. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream and make reload a no-op.
-- **The frames.** The first frame is a `stat` (`changed: false`) or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields `changed: true` with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata flagged `changed`, gone is a `not-found` frame with the previous value left for display. `reload(address)` stats again and yields `changed: false`. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file, or a reload, brings the resource live. Aborting the signal ends the stream silently.
+- **The value is metadata**, `WorkspaceFileStat { absolutePath, version, bytes? }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and compares versions to know when they are stale; freshness and refresh belong to each consumer, not the shared observation.
+- **The address names the file; its scope selects the Session.** A `session` address's relative or absolute path reaches the Host unchanged; the workspace root supplies the base for a relative path, and the Session filesystem backend decides read access. Client cwd is not a prerequisite. An `absolute` address has no Session and fails with `workspace-file/unknown-workspace`; no current or tab Session is borrowed. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream.
+- **The frames.** The first frame is a `stat` or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields that version with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata, gone is a `not-found` frame with the previous value left for display. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file brings the resource live. Aborting the signal ends the stream silently.
 - **One `changes` subscription per Session.** The first follower opens `remote.$stream`, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first `stat` only after accepting Host `ready`; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's `absolutePath`, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened.
 - **Navigation parameters.** `SidebarRightResourceParamsMap.file` is `WorkspaceFileParams { line?: number }`, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400.
 
 ### Related notes
 
-The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`.
+The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness.
 
 ## Alternatives considered
 
-**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading, statting, listing, and observing files inside a workspace root — and everything that queries workspace files belongs to it, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose.
+**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading and statting files plus listing and observing the workspace — and those queries belong together, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose.
 
 **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries.
 
 **Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises.
 
-**Log-reachable authorization for the read.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. Enough for produced files, but a typed path or a directory tree could never open. Path containment inside the workspace root was chosen, with the endpoint owning the constraints the filesystem's unconfined reads do not, and containment decided by `fs.contains` on resolved targets so a symlink cannot escape it.
+**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations.
 
 **Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window.
 
@@ -124,28 +127,25 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 **A default `readByteRange` in the `FileSystem` base class.** A non-abstract default over `readBytes` would have spared the test doubles a method but could only be implemented by reading the whole file up to the window's end, the very behaviour rejected above, or by passing an unbounded cap. Abstract, with every provider and double implementing it.
 
-**String-prefix containment.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment.
+**String-prefix containment for workspace operations.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment for `list` and `changes`.
 
 ## Consequences
 
 - Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate.
-- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host and never a whole file; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
+- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
 - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks.
-- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. An address built from another spelling of the same file — a workspace root reached through a symlink — opens and stats it, but its change frames never match, so `changed` stays false until a reload.
+- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key.
 - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version.
-- The gate order reports kind before position, a page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory, because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
+- File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
 - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action.
-- `readBytes` has no shipped consumer yet: it is the wire form the image and binary previews build on.
+- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats.
 
 ## Testing
 
-Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, reload, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
+Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
 
 ## Deferred
 
-- A web e2e chain through the Sidebar: open a file, have the agent write it, see `changed`, reload.
-- Aliasing a follower under the Host's canonical spelling once the first `stat` reveals it, so a symlinked workspace root still receives change frames.
 - A bound on a `changes` generation's queue.
 - The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only.
 - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one.
-- Reload delivery per record: today `reload` re-stats every follower of the file's absolute path in the session, so two records naming one file — a `session` and an `absolute` address, or two readers with different addresses — clear each other's `changed` flag.

+ 32 - 32
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md

@@ -8,30 +8,32 @@ Status: implemented
 
 Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工作区里的文件:agent 产出的文件、`read` 工具行点名的路径,之后还有文件树,以及既不小也不是文本的文件预览。唯一一个经线路读取工作区文件的端点以 `workspace-file.ts` 住在 Session Controller 上,与它毫无关系的会话生命周期为邻。它在一个总字节上限之下返回整个文件,因此大日志连一部分都看不了、二进制根本看不了;它没有 `stat`、没有列举、没有变更信号,预览不重读就无法得知 agent 已改写文件;其结果还以 Host 的 `url` 命名文件,而 Client 上没有任何东西把这种拼法当地址用。
 
-两个约束框定了任何答案。经 `ctx.fs` 的读取是有意不受限的——沙箱后端只围栏写与编辑,并明说了这一点——所以面向 web 的读端点必须自己拥有每一道围栏,而且围栏必须经得住一条离开工作区的符号链接,这是字符串前缀测试看不见的。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。
+两个约束框定了这项服务。经 `ctx.fs` 的文件读取使用 Session 组合后的文件系统后端,其读取权限可能延伸到工作区外,而目录树与变更流消费方以工作区为根。服务为普通文件保留后端的读取决策,同时执行文件类型与有界缓冲检查;`list` 与 `changes` 保留工作区包含限制。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。
 
 ## Decision
 
-`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并对内容分页或开窗,因此没有任何方法会缓冲整个文件。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。
+`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。文件方法从工作区根解析相对路径,但继承 Session 文件系统后端的读取权限;`list` 与 `changes` 仍限于工作区。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)拥有这一分层及其安全后果。结果以文件在文件系统执行环境中的绝对路径命名文件,内容则受页、字节窗口或整文件上限约束。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。
 
 ### 包拓扑
 
-[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、授权、分页和变更流约定保持不变。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。
+[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、分页和变更流约定保持不变。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)取代文件方法原有的工作区包含选择。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。
 
 | 面 | 包 | 文件 | 依赖 |
 |---|---|---|---|
 | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` |
 | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
 
-`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
+`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
 
 ### `workspaceFiles` Remote 命名空间
 
-每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。个签名照 `src/index.ts` 的声明:
+每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。个签名照 `src/index.ts` 的声明:
 
 ```ts ignore-check
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -40,25 +42,26 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。
 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。
+- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。
 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。
 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
 
 ### 线路上的路径
 
-离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。
+离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。
 
 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。
 
-### 四道关
+### 文件检查与工作区包含
 
-每次 `read`、`readBytes`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
 
 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
-2. **包含关系。** 路径解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`(会话 cwd,退而取策略配置的根)。`..` 爬出或根外绝对路径为 `outside-workspace`。从不使用字符串前缀比较:`resolve` 会取 realpath,前缀测试看不见离开根的链接
-3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。
+2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定
+3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
 
-过关之后文件方法再对目标 `stat` 一次,因为在检查与读取之间文件可能已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。关的顺序有一个可见后果:根外条目若类型本身已不合格,报告的是其种类而不是其位置。
+路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。
 
 ### 失败
 
@@ -67,20 +70,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 | 代码 | 何时 | Details |
 |---|---|---|
 | `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` |
-| `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` |
-| `workspace-file/too-large` | 一页文本或所请求的字节窗口超过 `maxBytes` | `{ path, limit }` |
+| `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` |
+| `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` |
 | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` |
-| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
+| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
 | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
 | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` |
-| `workspace-file/unknown-workspace` | Client 铸出:没有当前会话时的 `absolute` 地址 | `{ address }` |
+| `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` |
 | `gateway/bad-request` | 空路径,或不是范围内整数的 `offset`、`limit`、`length` | `{}` |
 
 这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。
 
 ### 配置
 
-个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限。文件本身没有大小上限:调用方分页或开窗读完它
+个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限;`maxFileBytes`(默认 33,554,432,即 32 MiB)限制全文及关联文件读取。分页和开窗读取不限制整个文件的大小
 
 ### `dsh-fs` 中的 `readByteRange` seam
 
@@ -96,27 +99,27 @@ abstract readByteRange(target: FsTarget, range: { offset: number; length: number
 
 ### Client `file` 提供者
 
-Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。
+Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。Document Preview 包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。
 
-- **值是元数据**,`WorkspaceFileResource { absolutePath, version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时
-- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址经当前会话读取,缺少当前会话时产生 `workspace-file/unknown-workspace`。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流,刷新无动作
-- **帧。** 第一帧是 `stat`(`changed: false`)或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生 `changed: true`、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是标为 `changed` 的新元数据,不在则是保留上一个值供展示的 `not-found` 帧。`reload(address)` 再 stat 一次并产生 `changed: false`。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件或一次刷新会让资源恢复正常。中止 signal 则流静默结束。
+- **值是元数据**,`WorkspaceFileStat { absolutePath, version, bytes? }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察
+- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对或绝对路径原样交给 Host;工作区根为相对路径提供基准,Session 文件系统后端决定读取权限。Client 不需要持有 cwd。`absolute` 地址不带 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 tab 所属 Session。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流。
+- **帧。** 第一帧是 `stat`或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生该版本、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是新元数据,不在则是保留上一个值供展示的 `not-found` 帧。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件会让资源恢复正常。中止 signal 则流静默结束。
 - **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。
 - **导航参数。** `SidebarRightResourceParamsMap.file` 是 `WorkspaceFileParams { line?: number }`,即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。
 
 ### 相关记录
 
-[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。
+[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。
 
 ## Alternatives considered
 
-**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——在工作区根内读取、stat、列举与观察文件——凡查询工作区文件的都归它,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。
+**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——读取和 stat 文件,并列举和观察工作区——这些查询应归于一处,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。
 
 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。
 
 **经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。
 
-**读取的"日志可达"授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件。对产出文件够用,但手输的路径或目录树永远打不开。选择了工作区根内的路径包含,端点自行承担文件系统不受限读取所不具备的约束,并由 `fs.contains` 对已解析目标判定包含关系,使符号链接无法逃逸
+**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制
 
 **为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。
 
@@ -124,28 +127,25 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 **在 `FileSystem` 基类里给 `readByteRange` 一个默认实现。** 基于 `readBytes` 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。
 
-**字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。由文件系统判定包含关系。
+**工作区操作使用字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。`list` 与 `changes` 由文件系统判定包含关系。
 
 ## Consequences
 
 - 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。
-- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存、从不整文件;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
+- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。
-- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。由同一文件另一种拼法铸出的地址——经符号链接到达的工作区根——能打开并 stat 它,但其变更帧永不匹配,因此 `changed` 在刷新前保持 false
+- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键
 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。
-- 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
+- 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。
-- `readBytes` 尚无随包交付的消费方:它是图片与二进制预览赖以构建的线路形态
+- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`
 
 ## Testing
 
-`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、刷新、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
+`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes` 流。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
 
 ## Deferred
 
-- 一条经 Sidebar 的 web e2e 链:打开文件、让 agent 写它、看到 `changed`、刷新。
-- 在首次 `stat` 揭示 Host 的规范拼法后为跟随者加别名,使经符号链接的工作区根也能收到变更帧。
 - 给 `changes` 一代流的队列加上限。
 - `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。
 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。
-- 按记录投递重载:今天 `reload` 重新 stat 该会话中此文件绝对路径的所有跟随者,因此命名同一文件的两条记录——`session` 与 `absolute` 地址,或地址不同的两个读者——会互相清掉 `changed` 标记。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-08-document-preview-operations.md
+2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178
+2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd

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

@@ -0,0 +1,39 @@
+# Agent Note: Document preview and file addresses
+
+Status: implemented
+
+English | [中文](2026-09-08-document-preview-operations.zh.md)
+
+## Problem
+
+File viewers need different loading policies and may offer several implementations for one extension. A change stream cannot also express an on-demand read without mixing live data with callable capabilities. HTML dependencies additionally need the Host's filesystem authorization and path resolution, not the browser's current directory.
+
+## Decision
+
+Document Preview separates resource observation from content reads. The [resource model](2026-09-05-client-resource-model.md) shares observations by address alone: `source(address)`, `pin(address, signal)`, and provider `open(address, { signal })` carry no consuming Session. Providers return `AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>`; `useResource` exposes only `{ status, value, failure }`. Holds start and stop observation, not the underlying file or Session. Content reads use ordinary injected Preview callbacks.
+
+[Workspace Files](../../../../packages/api/workspace-files/README.md) retains Host line reads, byte windows, bounded complete reads, and bounded reads relative to another file's directory. Its Client `file` provider observes only `stat` and `changes`, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. The Host resolves every path through the Session filesystem; file reads inherit that backend's read authority, while directory listing and change observation stay workspace-scoped.
+
+Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
+
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob.
+
+## Alternatives considered
+
+**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.
+
+**UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation.
+
+**File-reading methods on every resource.** Chat and terminal resources have independent data and operation semantics; only observation registration and lifetime are common.
+
+**A preview resource wrapper, content Session, or second resource Hook.** These duplicate addressing, cancellation, subscriptions, and ownership already provided by Resource and Workspace Files. Loading policy belongs to the preview owner.
+
+**A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support.
+
+## Consequences
+
+Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering.

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

@@ -0,0 +1,39 @@
+# Agent Note: 文档预览与文件地址
+
+Status: implemented
+
+[English](2026-09-08-document-preview-operations.md) | 中文
+
+## 问题
+
+文件预览器需要不同的加载策略,同一扩展名也可能对应多种实现。变更流无法同时表达按需读取而又不把实时数据与可调用能力混在一起。HTML 依赖还需要 Host 的文件系统授权和路径解析,而非浏览器的当前目录。
+
+## 决策
+
+Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-05-client-resource-model.zh.md)只按地址共享观察:`source(address)`、`pin(address, signal)` 和提供方的 `open(address, { signal })` 均不携带消费 Session。提供方返回 `AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>`;`useResource` 只暴露 `{ status, value, failure }`。持有只控制观察的启停,不控制底层文件或 Session 的生灭。内容通过普通注入的 Preview 回调读取。
+
+[Workspace Files](../../../../packages/api/workspace-files/README.zh.md) 保留 Host 的行读取、字节窗口、有上限的全文读取和相对另一文件目录的有界读取。Client `file` 提供方只观察 `stat` 与 `changes`,`ResourceProtocolMap.file` 直接为 `WorkspaceFileStat`。Host 通过 Session 文件系统解析每条路径;文件读取继承该后端的读取权限,目录列举与变更观察仍限定于工作区。
+
+可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
+
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。
+
+## 考虑过的替代方案
+
+**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
+
+**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。
+
+**把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。
+
+**让所有资源提供文件读取方法。** Chat 与终端资源的数据和操作语义各自独立,只有观察的注册和生命周期是共用机制。
+
+**预览资源包装层、内容 Session 或第二个资源 Hook。** 这些方案重复了 Resource 和 Workspace Files 已提供的寻址、取消、订阅和归属。加载策略属于预览所有者。
+
+**本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。
+
+## 影响
+
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.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-08-global-main-panels.md
+2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
+2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -0,0 +1,37 @@
+# Agent Note: Global main panels without default UI additions
+
+Status: implemented
+
+English | [中文](2026-09-08-global-main-panels.zh.md)
+
+## Problem
+
+Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application.
+
+## Decision
+
+The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
+
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+
+One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
+
+`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event.
+
+DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection.
+
+## Alternatives considered
+
+**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current.
+
+**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations.
+
+**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation.
+
+**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection.
+
+## Consequences
+
+The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work.
+
+Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 不增加默认界面的全局主面板
+
+Status: implemented
+
+[English](2026-09-08-global-main-panels.md) | 中文
+
+## 问题
+
+插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。
+
+## 决策
+
+布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
+
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+
+渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
+
+`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。
+
+DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。
+
+## 考虑过的替代方案
+
+**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。
+
+**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。
+
+**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。
+
+**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。
+
+## 后果
+
+默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。
+
+面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.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-workspace-file-read-authority.md
+2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3
+2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md

@@ -0,0 +1,29 @@
+# Agent Note: Workspace file read authority
+
+Status: implemented
+
+English | [中文](2026-09-09-workspace-file-read-authority.zh.md)
+
+## Problem
+
+Workspace Files serves both file content and workspace navigation. Applying workspace containment to every operation creates a second read policy above the Session filesystem backend and prevents a user from previewing paths that the same Session can read outside its workspace. HTML preview also needs direct relative JavaScript and stylesheet files, including `..` paths, while its script-enabled document can use the browser network.
+
+## Decision
+
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps.
+
+`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.
+
+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.
+
+## Alternatives considered
+
+**Contain every operation within the workspace.** This gives previews a narrower policy than the Session filesystem backend, blocks explicitly addressed readable files, and prevents HTML beside external assets from rendering. Workspace containment remains where the operation itself represents the workspace.
+
+**Permit outside reads but block all iframe networking.** A stricter CSP would reduce exfiltration risk, but it would also reject external assets and network behavior intentionally retained for the static-HTML preview. The opaque sandbox protects the parent application; it does not promise network isolation.
+
+## 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.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 工作区文件读取权限
+
+Status: implemented
+
+[English](2026-09-09-workspace-file-read-authority.md) | 中文
+
+## Problem
+
+Workspace Files 同时提供文件内容与工作区导航。对所有操作应用工作区包含限制,会在 Session 文件系统后端之上形成第二套读取策略,并阻止用户预览同一 Session 在工作区外可读的路径。HTML 预览还需要直接读取相对 JavaScript 与样式表文件,包括含 `..` 的路径,而启用脚本的文档可以使用浏览器网络。
+
+## Decision
+
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。
+
+`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。
+
+`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。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。
+
+## Alternatives considered
+
+**把所有操作限制在工作区内。** 这会让预览采用比 Session 文件系统后端更窄的策略,阻止读取明确寻址的可读文件,并使位于外部资源旁的 HTML 无法渲染。操作本身代表工作区时,仍保留工作区包含限制。
+
+**允许根外读取,但阻断 iframe 的全部网络。** 更严格的 CSP 可以降低数据外传风险,但也会拒绝静态 HTML 预览有意保留的外部资源与网络行为。不透明 sandbox 保护父应用,但不承诺网络隔离。
+
+## Consequences
+
+持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.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-07-pi-ai-settings-catalog-recovery.md
+2026-09-07-pi-ai-settings-catalog-recovery.md: 21fe532a491775ff875f6b9bcb000d917a95e13c
+2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 80bd0758e13b767671f4cec52ee3832c9250651f

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md

@@ -0,0 +1,39 @@
+# Agent Note: Repairable pi-ai settings after catalog changes
+
+Status: implemented
+
+English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.zh.md)
+
+## Problem
+
+An installed pi-ai catalog can change the validity of unchanged user settings. OpenRouter models outside the catalog can inherit a protocol while all shipped models agree; adding a second protocol removes that inference. Removing a catalog model also invalidates an override keyed by its former id. Rejecting the entire settings namespace at registration makes unrelated providers disappear and removes the controls needed to repair the configuration.
+
+## Decision
+
+The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged.
+
+Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
+
+Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
+
+`LlmConfigurableProvider.error` carries the first available model diagnostic for the provider row, falling back to the route error. A provider-construction failure does not overwrite a collected model diagnostic, preserving the specific correction for a missing protocol. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
+
+This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale.
+
+## Alternatives considered
+
+**Reject catalog errors at registration.** This prevents users from repairing an otherwise parseable configuration and lets an unused stale model disable unrelated providers.
+
+**Relax save validation too.** A newly entered model with no inferable protocol can be rejected immediately with the offending provider and model named. Accepting it creates an avoidable request-time failure.
+
+**Strictly revalidate the entire namespace on every save.** An unrelated provider's old error would block adding a healthy provider or repairing providers independently.
+
+**Assign OpenRouter a fixed route protocol.** A route override replaces every model's protocol and can change working catalog entries that intentionally use another API.
+
+## Consequences
+
+Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field.
+
+## Testing
+
+Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: pi-ai 目录变化后可修复的设置
+
+Status: implemented
+
+[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文
+
+## Problem
+
+已安装的 pi-ai 目录可能改变未修改用户设置的有效性。OpenRouter 的目录外模型可以在所有内置模型协议一致时继承协议;加入第二种协议会使这种推断失效。删除目录模型也会使按其旧 ID 声明的覆盖失效。注册时拒绝整个 settings 命名空间,会让无关提供方消失,也移除了修复配置所需的控件。
+
+## Decision
+
+pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变。
+
+首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
+
+Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
+
+`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
+
+本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。
+
+## Alternatives considered
+
+**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。
+
+**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。
+
+**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。
+
+**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。
+
+## Consequences
+
+依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。
+
+## Testing
+
+适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-31-even-out-shipped-tool-rosters.md
-2026-07-31-even-out-shipped-tool-rosters.md: df334bc0bd9d011ea2e2f4ee938fb29c4c4bfe2c
-2026-07-31-even-out-shipped-tool-rosters.zh.md: ddee380f836bf50a733541b2d3ea6dd8a74126a2
+2026-07-31-even-out-shipped-tool-rosters.md: caadb4601a56e9180ccb8d1eae571208941cbcba
+2026-07-31-even-out-shipped-tool-rosters.zh.md: 9503baf9f1f8f2567c3ccd81db685c74db189430

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md

@@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask
 
 ## Decision
 
-The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`.
+The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) removes `tool-str-replace-editor` from general-purpose presets, and the [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes it from the minimal compositions.
 
 Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。
+那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。后续决策收窄了这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一 editor 决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)从通用 preset 移除 `tool-str-replace-editor`,[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)则从极简组合移除它。
 
 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-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-08-11-minimal-profiles-bare-two-tool-runtime.md
-2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 9e4d476c36ab6f920481b1281058d888710a2ef5
-2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 761c384f1656ce66d027eb2b13c9dbc4f20f3686
+2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 8cfa4aa71e33317947d0661641df1e6e814f5e90
+2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: f1f916bdde92fd104c3d879ff058b2e0474eef38

+ 7 - 7
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md

@@ -1,4 +1,4 @@
-# Agent Note: Minimal profiles use the bare two-tool runtime
+# Agent Note: Minimal profiles use a bare runtime
 
 Status: implemented
 
@@ -12,23 +12,23 @@ The two launch paths also have different configuration owners. Web mounts a per-
 
 ## Decision
 
-The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context.
+The shipped Web minimal preset exposes persistent `bash`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows. Both mount no context-compaction or filesystem provider and suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions. The [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes the editor and its otherwise-unused `fs-local` provider from both compositions. The Web preset's persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context.
 
-The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`.
+The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, that shell's tool consumer, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-local`, `fs-sandbox`, `fs-observation-policy`, or a filesystem tool. The persistent shell consumes the profile's danger-full-access sandbox policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`.
 
 `DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../python/sdk/examples/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path.
 
 ## Verification
 
-The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services.
+The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that no scoped filesystem or compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and persistent Bash. It then executes persistent Bash against the real scoped services.
 
-The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
+The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact single-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
 
 ## Alternatives considered
 
 **Keep `compaction-basic` mounted with a high threshold.** Rejected because even an inert-for-short-tests provider permits history replacement in longer sessions and leaves the minimal composition dependent on model-capacity metadata and the token meter.
 
-**Keep `fs-sandbox` in danger-full-access mode.** Rejected because the sandboxed provider still makes confinement and escalation part of the editor capability. The target runtime requires the bare local provider, whose lack of `sandboxMode` is composition truth.
+**Keep `fs-local` after removing the editor.** Rejected because neither minimal composition has another filesystem consumer. Retaining the provider would increase the runtime roster without adding a model-visible capability.
 
 **Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies.
 
@@ -36,4 +36,4 @@ The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a
 
 ## Consequences
 
-Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile.
+Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The two launch paths share their single-tool, no-filesystem, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile.

+ 7 - 7
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: minimal profile 使用裸双工具运行时
+# Agent Note: minimal profile 使用裸运行时
 
 Status: implemented
 
@@ -12,23 +12,23 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash`
 
 ## 决策
 
-随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。
+随附 Web minimal preset 对外提供持久 `bash`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`。两者都不挂载上下文压缩或文件系统提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献。[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)从两份组合中移除了编辑器及其原本除此之外无人使用的 `fs-local` 提供方。Web preset 的 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。
 
-独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。
+独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、该 shell 的工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-local`、`fs-sandbox`、`fs-observation-policy` 或文件系统工具。持久 shell 消费该 profile 的 danger-full-access 沙箱策略。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。
 
 `DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../python/sdk/examples/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。
 
 ## 验证
 
-Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器
+Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言不存在作用域文件系统或压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与持久 Bash。随后,它通过真实作用域服务执行持久 Bash。
 
-SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
+SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
 
 ## 考虑过的替代方案
 
 **以较高阈值保留 `compaction-basic`。** 不予采用,因为即便提供方在短测试中未触发,较长会话仍允许替换历史记录,而且 minimal 组合仍会依赖模型容量元数据与 token meter。
 
-**在 danger-full-access 模式下保留 `fs-sandbox`。** 不予采用,因为沙箱提供方仍会使限权与提权成为编辑器能力的一部分。目标运行时要求裸本地提供方,而其不具备 `sandboxMode` 正是组合事实
+**移除编辑器后保留 `fs-local`。** 不予采用,因为两份 minimal 组合都没有其他文件系统消费方。保留该提供方只会扩大运行时清单,不会新增模型可见能力
 
 **为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。
 
@@ -36,4 +36,4 @@ SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境
 
 ## 后果
 
-Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。
+Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。两条启动路径共享单工具、无文件系统、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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-04-right-sidebar-docking-infrastructure.md
-2026-09-04-right-sidebar-docking-infrastructure.md: 8e1d1b5518c3b80a05be877ee9796883b6eb9a60
-2026-09-04-right-sidebar-docking-infrastructure.zh.md: 6830bf74734bbc8072b201ded6194e1d20ddbc88
+2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e
+2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710

+ 2 - 0
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md

@@ -37,6 +37,8 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
 
 ### State
 
+[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes.
+
 `ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
 
 ### Beyond the surface

+ 2 - 0
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md

@@ -37,6 +37,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 ### 状态
 
+[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。
+
 `ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
 
 ### 面之外

+ 2 - 2
.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: a21946c73302561a2cb8539ca314e0a5d4fb25b8
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: dc02c159e0e052a21f2f1c80ac2e6f30bf5cf416
+2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8

+ 13 - 10
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md

@@ -12,10 +12,12 @@ Each answer carries product rules that code alone does not explain: why a text f
 
 ## Decision
 
-Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **text preview** (`ui-sidebar-textpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the text preview is a viewer that claims every `file` resource address at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarTextpreview`, `sidebarFiles`).
+Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **document preview** (`ui-sidebar-documentpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the document preview is a viewer that claims Session-scoped `file` resource addresses at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarDocumentPreview`, `sidebarFiles`).
 
 ### The guide
 
+[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes this section's default-guide selection; guide registration, replacement and uniqueness remain unchanged.
+
 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 is a centred column — a lead line (`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`), one line of copy (`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`), and a grid of entry boxes at most 480px wide, each box at least 160px, filling as many columns as fit. The boxes are projected from 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. A box shows the contributing type's glyph, title, and description, and picking it 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.
@@ -26,21 +28,23 @@ A pane holds at most one guide, and the docking layer enforces it as product beh
 
 ### The text preview
 
-`text` is the fallback viewer for every file. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened.
+The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) supersedes this section's renderer, loading, and resource-observation details. The fallback tab registration, paged source navigation, and body-owned controls remain in force.
+
+`text` is the fallback viewer for Session-scoped files. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`. `canOpen` accepts only addresses whose parsed scope is `session`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened.
 
-A tab's address is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path>` ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers in `dsh-util-workspace-path`). The preview never splits the string itself: `hostFileOf` in `rpc.ts` calls `parseFileAddress` and yields the `{ sessionId, path }` the endpoint takes — a `session` address reads under the session it names with the relative path the Host resolves, an `absolute` address reads under the session the slot was mounted for with the absolute path — and a malformed address throws, a programming error, because the registry routes every `file` address to this type and a caller building one is expected to use the helper.
+A tab uses `dsh-resource://file/session/<sessionId>/<path>`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error.
 
-Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `{ absolutePath, version, bytes, changed }` from the `file` provider; the body reads `changed` and the resource's failed state. Content is the type's own business, read one page of lines at a time through `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` with no `limit`, so the page length is the Host's configured cap (`maxLines`, 5000 lines by default, and a page may not exceed `maxBytes`, 2 MB by default). The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next page until `eof`, disabled and reading `正在读取…` / `Reading…` while a read is in flight, and absent once the file has ended or a page failed. Pages are appended in file order with no separators and no line numbers, each carrying its line count (`lines`) so one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted and the walk restarts from the first page, so the body never shows two versions at once. The face keeps a request generation per tab: a reload bumps it, and a page settling from an older generation writes nothing. A tab switched away from and back reads nothing, because the pages live in the store, not the body.
+Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading.
 
 The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload.
 
 Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `openResource(address, { params: { line } })`, and the produced-file chip passes nothing; the body narrows `navigation.params` to `SidebarRightResourceParamsMap['file']` (`{ line?: number }`, declared by the `file` type's owner) without runtime validation, because caller and body meet at a typed same-process boundary. If the loaded pages do not reach the line, the body reads the next page, again, until they do or the file ends — pages load in order; there is no seek — then scrolls the line to the top of the body and highlights it, once per `navigation.revision`. The store records the answered revision, so a body remounting for the same revision restores the scroll offset instead of jumping, and a new `openResource` for the same file (revealed, not duplicated) arrives as a new revision and jumps again. A line past the end of the file stops silently at `eof`; a page that fails while walking stops the walk and shows the failure line.
 
-A changed file is announced, not applied. When the `file` resource reports `changed` — the agent wrote the file through a tool after the last `stat` — a bar above the path row says `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.` with a `重新载入` / `Reload` button. Only the click does two things at once: `meta.reload()` (a fresh `stat`, which clears `changed`) and `reloadPages` (drop every page, read the first one again). The scroll offset is kept, so the reader stays where they were. Nothing else triggers a reload: the tree and the preview do not watch the filesystem, and an external edit is not announced. A resource that turns `failed` — the file deleted, or the Host refusing it — puts a failure bar in the same place, its line from `failure-line.ts` and the same reload button, ahead of any pending `changed`; the pages already read stay beneath it.
+A changed file is announced, not applied. The body compares the loaded version and the observation captured at read start with later `WorkspaceFileStat.version`; a difference shows the change bar. Reload rereads only this tab through the Preview face and does not mutate shared resource metadata or another tab. A resource failure takes the same bar's place above any content already loaded.
 
-The body's header is one row: the file's path as the address names it on the left (12px, tertiary colour, one line, ellipsis when it overflows, full path on hover) and two 24px controls at its right end — a wrap toggle (`自动换行` / `Wrap lines`, pressed state shown, **on by default** per tab: long lines wrap and never scroll horizontally until the reader turns it off, whereupon the file body scrolls horizontally on its own) and a reload button (`重新读取文件` / `Read the file again`) that does exactly what the change bar's button does. Neither control is ever disabled. The preview takes the pane body's full height (`height: 100%` against the pane body, which is a block scroller of definite height) so a short file leaves no separately styled space below it, and the file body — monospace, 13px, line height 1.6, 10px vertical padding — is the only scroller: the header and the change bar stay put while a long file scrolls under them.
+The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls and renderer behavior. The preview takes the pane body's full height, and its document body is the scroller beneath the fixed header and change bar.
 
-A failed page keeps the pages already shown and adds one sentence at the end of the loaded text, in terms of the file rather than the transport, with a `重试` / `Retry` button that reads the same page again: `workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`; `workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`; `workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.` with the byte cap rendered as `2 MB`; `workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`; `workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`; any other failure, carrier or unclassified, `读取失败:{message}` / `Read failed: {message}` with the failure's own message. The mapping lives in `failure-line.ts`, apart from the component so it is testable on its own; a code the reader does not name falls to the generic line carrying the carrier's message. A directory or a binary file therefore shows one failure line and nothing else; an empty file shows the header and an empty body with no marker.
+A failed read keeps content already shown and adds a localized failure with a retry action. The Preview names actionable file failures and falls back to the carrier message for other codes; `outside-workspace` belongs to directory listing and is not a Preview-specific failure.
 
 ### The file tree
 
@@ -94,7 +98,7 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「
 
 ## Consequences
 
-- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-textpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback.
+- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-documentpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback.
 - Reading by page bounds every request (`maxLines` lines, `maxBytes` bytes) at the cost of a **Load more** control, no total line count, and sequential walks to a deep line; a navigation to line 40,000 of a large file reads eight pages first.
 - Announcing a change instead of applying it keeps the reader's place during an agent's repeated writes, at the cost of showing stale text until the reader clicks; an external edit is never announced.
 - Reload reads the first page only, so a reader deep in a file reloads into the top of it and pages forward again; the scroll offset is preserved but may point past the loaded text.
@@ -109,10 +113,9 @@ The text preview's `tests/` cover the registry claim and yielding (through the r
 ## Deferred
 
 - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
-- Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or end-of-file marker.
+- Images, search, a total line count, and an end-of-file marker.
 - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree.
 - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries.
-- Chinese README counterparts for `ui-sidebar-textpreview` and `ui-sidebar-files`.
 
 ## Related
 

+ 13 - 10
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md

@@ -12,10 +12,12 @@ Status: implemented
 
 ## Decision
 
-Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文本预览**(`ui-sidebar-textpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文本预览是以最低档认领每个 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarTextpreview`、`sidebarFiles`)以 locale 方式持有。
+Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文档预览**(`ui-sidebar-documentpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文档预览是以最低档认领 Session 作用域 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarDocumentPreview`、`sidebarFiles`)以 locale 方式持有。
 
 ### 引导页
 
+[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代本节的默认引导选择;引导页注册、替换和唯一性保持不变。
+
 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。
 
 体是一根居中的列——一句引导语(`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`)、一行文案(`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`),以及一组最宽 480px 的入口框栅格,每框至少 160px,能放几列放几列。入口框按 `order` 从每个已注册类型的 `guide[]` 投影而来,经注册表可观察的 `guide()` 列表,因此后注册的类型不用引导页知道就能出现。一个框显示贡献类型的图标、标题与说明;点选它调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
@@ -26,21 +28,23 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
 
 ### 文本预览
 
-`text` 是每个文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。
+[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)取代本节的渲染器、加载和资源观察细节。兜底 tab 注册、分页源码导航与正文自有控件仍然有效。
+
+`text` 是 Session 作用域文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`。`canOpen` 只接受解析后 scope 为 `session` 的地址。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。
 
-tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<绝对路径>`([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md) 拥有这套语法及 `dsh-util-workspace-path` 里的 `fileAddressFor` / `parseFileAddress` 助手)。预览从不自己拆这个串:`rpc.ts` 里的 `hostFileOf` 调 `parseFileAddress` 得到端点所需的 `{ sessionId, path }`——`session` 地址在它命名的会话下以 Host 解析的相对路径读取,`absolute` 地址在坑位被挂载的会话下以绝对路径读取——畸形地址直接抛错,那是程序错误,因为注册表把每个 `file` 地址都路由给这个类型,而造地址的调用方本应使用助手。
+tab 使用 `dsh-resource://file/session/<sessionId>/<path>`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出
 
-元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——从 `file` 提供者得到 `{ absolutePath, version, bytes, changed }`;体读 `changed` 与资源的失败态。内容是类型自己的事,经 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` 一次读一页行,不传 `limit`,因此页长就是 Host 配置的上限(`maxLines`,默认 5000 行;且一页不得超过 `maxBytes`,默认 2 MB)。首次挂载读第 1 页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`,读取进行中它禁用并显示 `正在读取…` / `Reading…`,文件读完或某页失败后消失。页按文件顺序追加,没有分隔也没有行号,每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用,从第一页重新走一遍,于是体永不同时显示两个版本。face 按 tab 记请求代次:重载递增它,旧代次结算的页什么也不写。切走再切回的 tab 什么都不读,因为页住在 store 里而不是体里
+元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式
 
 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。
 
 导航是一个 `line`。`read` 工具行把它 1 起的 `offset` 以 `openResource(address, { params: { line } })` 传来,产物 chip 什么都不传;体把 `navigation.params` 收窄为 `SidebarRightResourceParamsMap['file']`(`{ line?: number }`,由 `file` 类型的拥有者声明),不做运行时校验,因为调用方与体相遇在同进程的类型化边界上。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束——页按顺序加载,没有 seek——然后把该行滚到体顶部并高亮,每个 `navigation.revision` 一次。store 记下已答过的 revision,于是同一 revision 下重新挂载的体恢复滚动位置而不再跳;对同一文件再次 `openResource`(聚焦而非复制)以新 revision 到来并再跳一次。超出文件末尾的行在 `eof` 处静默停下;补页途中失败的页终止补页并显示失败行。
 
-文件变了只提示,不应用。当 `file` 资源报告 `changed`——agent 在上次 `stat` 之后经工具写了该文件——路径行上方出现一条提示 `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.`,带一个 `重新载入` / `Reload` 按钮。只有点击才同时做两件事:`meta.reload()`(重新 `stat`,清掉 `changed`)与 `reloadPages`(丢掉所有页,重读第 1 页)。滚动位置保留,读者停在原处。没有别的东西触发重载:树和预览都不监听文件系统,外部编辑不会被提示。资源变为 `failed`——文件被删,或 Host 拒绝——时,同一位置出现一条失败条,句子来自 `failure-line.ts`,带同一个重新载入按钮,并优先于尚未处理的 `changed`;已读的页留在它下方。
+文件变了只提示,不应用。正文把已加载版本及读取开始时捕获的观察版本与后续 `WorkspaceFileStat.version` 比较;不同则显示变更提示。重新载入只通过 Preview face 重读当前 tab,不修改共享资源元数据或其他 tab。资源失败占用同一个提示位置,已加载内容仍保留在下方。
 
-体的头部是一行:左边是地址所命名的文件路径(12px、三级色、单行、溢出省略号、悬停显示完整路径),右端是两个 24px 控件——换行开关(`自动换行` / `Wrap lines`,显示按下态,**默认开**、按 tab 记:长行折行、绝不横向滚动,直到读者关掉它,此后文件体自己横向滚动)与一个重新读取按钮(`重新读取文件` / `Read the file again`),做的恰是变更提示条按钮做的事。两个控件都永不禁用。预览占满 pane 体的全部高度(对 pane 体取 `height: 100%`;pane 体是高度确定的块级滚动容器),于是短文件下方不留另一块样式不同的空白,而文件体——等宽、13px、行高 1.6、上下 10px 内边距——是唯一的滚动者:长文件在头部与变更提示条之下滚动,二者不动
+正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件与渲染器行为。预览占满 pane 正文的全部高度,其文档正文是固定头部与变更提示条下方的滚动区域
 
-某页失败时,已显示的页保留,并在已加载文本末尾加一句以文件而非传输为主语的说明,带一个重读同一页的 `重试` / `Retry` 按钮:`workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`;`workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`;`workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.`,字节上限渲染为 `2 MB` 这样的形式;`workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`;`workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`;其余任何失败,无论载体层还是未分类,`读取失败:{message}` / `Read failed: {message}` 并带上失败自身的消息。映射住在 `failure-line.ts` 里,与组件分开以便单独测试;读者未命名的错误码落到带传输层消息的通用句。目录或二进制文件因此只显示一行失败说明;空文件显示头部与一个空的体,没有任何标记
+读取失败时保留已显示的内容,并增加本地化失败说明与重试操作。Preview 为可处理的文件错误提供专用文案,其他代码使用载体消息兜底;`outside-workspace` 属于目录列举,不是 Preview 专用失败
 
 ### 文件树
 
@@ -94,7 +98,7 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 
 ## Consequences
 
-- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-textpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。
+- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-documentpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。
 - 按页读取让每次请求都有界(`maxLines` 行、`maxBytes` 字节),代价是一个 **加载更多** 控件、没有总行数,以及到深处某行的顺序补页;导航到一个大文件的第 40,000 行要先读八页。
 - 只提示不应用,让读者在 agent 反复写入期间保住位置,代价是点击之前显示的是旧文本;外部编辑永不提示。
 - 重新载入只读第 1 页,所以身在文件深处的读者重载后回到文件开头再往后翻;滚动位置保留但可能指向已加载文本之外。
@@ -109,10 +113,9 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 ## Deferred
 
 - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
-- 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。
+- 图片、搜索、总行数与文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
-- `ui-sidebar-textpreview` 与 `ui-sidebar-files` 的中文 README 对照。
 
 ## Related
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-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-08-present-workspace-source-files.md
+2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d
+2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94

+ 37 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -0,0 +1,37 @@
+# Agent Note: Present declares workspace source files
+
+Status: implemented
+
+English | [中文](2026-09-08-present-workspace-source-files.zh.md)
+
+## Problem
+
+Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow.
+
+## Decision
+
+The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. 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 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.
+
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior.
+
+## Alternatives considered
+
+**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision.
+
+**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications.
+
+**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules.
+
+**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text.
+
+**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees.
+
+## Consequences
+
+The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged.
+
+The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced.
+
+Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export.

+ 37 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:Present 声明交付工作区源文件
+
+Status: implemented
+
+[English](2026-09-08-present-workspace-source-files.md) | 中文
+
+## 问题
+
+用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
+
+工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
+
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。
+
+## 考虑过的替代方案
+
+**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。
+
+**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。
+
+**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。
+
+**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。
+
+**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。
+
+## 影响
+
+Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。
+
+仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。
+
+定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.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-08-sidebar-default-pages.md
+2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4
+2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md

@@ -0,0 +1,27 @@
+# Agent Note: Sidebar default pages and close protection
+
+Status: implemented
+
+English | [中文](2026-09-08-sidebar-default-pages.zh.md)
+
+## Problem
+
+A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again.
+
+## Decision
+
+The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one.
+
+A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
+
+This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active.
+
+## Alternatives considered
+
+**Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry.
+
+**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior.
+
+## Consequences
+
+One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: Sidebar 默认页与关闭保护
+
+Status: implemented
+
+[English](2026-09-08-sidebar-default-pages.md) | 中文
+
+## 问题
+
+只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。
+
+## 决策
+
+Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。
+
+单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
+
+本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。
+
+## 考虑过的替代方案
+
+**统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。
+
+**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。
+
+## 后果
+
+单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。

+ 2 - 2
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-30-generated-third-party-notices.md
-2026-07-30-generated-third-party-notices.md: b81f386f0d0e820b0361c775fb4c45a9e633d04b
-2026-07-30-generated-third-party-notices.zh.md: d3b3e1686bfdf8901902f995205c1dbdd6dfbf38
+2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9
+2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31

+ 1 - 1
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md

@@ -20,7 +20,7 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi
 
 The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail.
 
-**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
+**Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook.
 
 The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice.
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。
 
-**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记
+**分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行
 
 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: c403964d8de1c158e5949b5e112a038832709874
-2026-08-10-npm-release-sequences.zh.md: d03da4c4485c4807497dfb28b61ab342bb4c8d12
+2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5
+2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -123,6 +123,8 @@ A dsh verification installs the vendored family's pack output too. The harness p
 
 The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency.
 
+The installed-consumer probe captures npm's HTTP diagnostics and includes them when installation fails. Registry response codes and cache status remain visible even when npm reports a failed peer manifest fetch as `ERESOLVE` with an undefined version.
+
 ### Repository changes this carried
 
 | Item | Content |

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -123,6 +123,8 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
 
 验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。
 
+已安装消费方探针会捕获 npm 的 HTTP 诊断信息,并在安装失败时输出。即使 npm 将 peer manifest 获取失败报告为版本未定义的 `ERESOLVE`,日志中仍能看到 registry 响应码和缓存状态。
+
 ### 本次带出的仓库改动
 
 | 项 | 内容 |

+ 2 - 2
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md
-2026-08-26-published-dependency-faces.md: 25e9f2ce139a7cd4efb64dbe71d49d8c9f88c24b
-2026-08-26-published-dependency-faces.zh.md: ccc198b164b7450b6840862faf23b546c99fa2a6
+2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460
+2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4

+ 1 - 1
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md

@@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur
 
 An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified.
 
-Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`.
+Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`.
 
 Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md

@@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只
 
 constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。
 
-Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。
+Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。
 
 部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.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-08-browser-third-party-build-inputs.md
+2026-09-08-browser-third-party-build-inputs.md: 97e61f3b99955cb402f9e622b2d40e3ee2953d79
+2026-09-08-browser-third-party-build-inputs.zh.md: e23c4e9d4615c7c5bc788beafcf6b94699f211ce

+ 35 - 0
.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md

@@ -0,0 +1,35 @@
+# Agent Note: Browser third-party libraries as build inputs
+
+Status: implemented
+
+English | [中文](2026-09-08-browser-third-party-build-inputs.zh.md)
+
+## Problem
+
+Prebuilt browser plugins distribute their third-party implementations inside JavaScript, but production npm dependencies still make installers download those libraries separately and resolve their peers. When React is declared only for development, installers can select a different React version for those extra dependencies than the browser artifact uses. Both `use-sync-external-store@1.2.0` and `@tanstack/react-virtual@3.14.9` support the current React 18; this problem does not require a React upgrade.
+
+## Decision
+
+Browser-only third-party dependencies belong in `devDependencies`, including implementations inlined into dynamic plugins, static browser-library inputs, and React shared by the Web shell. This partially supersedes the preservation of ordinary third-party declarations in [published dependency faces](2026-08-26-published-dependency-faces.md); that note continues to govern package selection, Host value dependencies, and Cordis identity.
+
+The dependency classifier collects build inputs from source imports and JSX. Third-party libraries reachable by the Host runtime take precedence as `dependencies`; additional Node build entries must also be checked. Type-only source references do not create Host runtime dependencies. Existing configuration-metadata and shared-Host-export classifications remain unchanged.
+
+Npm sections do not select browser bundling behavior. Dynamic plugins inline private libraries and obtain React and other shared modules from the platform table; static browser libraries retain bare imports and styles for the final Vite build. Static packages are Web-shell build inputs, not independently installed libraries with every rebundling dependency provided. Source builds need development dependencies; installed published Web artifacts do not.
+
+License classification follows distributed content. Dependency resolution through the real browser build configurations covers dynamic plugins and the Web shell; resolved third-party implementations remain [runtime disclosures](2026-07-30-generated-third-party-notices.md) even when manifests declare them for development. Test tools, erased type imports, and build tools do not become distributed code merely by appearing in `devDependencies`.
+
+## Alternatives considered
+
+**Declare another production React or override peer resolution.** This retains an extra installed graph that the browser does not use, without making that installed copy the browser's shared instance.
+
+**Inline every static-library dependency early.** This changes Vite's third-party chunks, caching, and CSS handling; dependency classification does not require those build changes.
+
+**Move every third-party dependency of a Client-bearing package.** Dual-face packages still load Host libraries, including `fflate` for ZIP output and `zod` for RPC validation; those installation relationships must remain.
+
+**Classify license disclosures directly by manifest section.** Distributing browser code and having an installer download a same-named package are different facts; that distinction cannot remove license checks on distributed code.
+
+## Consequences
+
+Production dependencies do not install third-party libraries a second time solely for browser implementations. React and React DOM retain one build version, and plugins consume the Web shell's shared instance. Classification tests constrain browser dev-only inputs, Host precedence, and idempotent repair; publication checks reject React installation leaks, while browser artifact verification independently covers module loading.
+
+Classification remains source-based. License resolution likewise must not depend on existing `lib/` files or write build outputs; its tests cover real resolution, type erasure, asset references, and missing dependencies. Developers independently consuming static packages or public types supply the corresponding build dependencies themselves; this decision adds no standalone browser-library support promise.

+ 35 - 0
.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 浏览器第三方库作为构建输入
+
+Status: implemented
+
+[English](2026-09-08-browser-third-party-build-inputs.md) | 中文
+
+## 问题
+
+预构建浏览器插件的第三方实现已经随 JavaScript 分发,但生产 NPM 依赖仍让安装器另外下载这些库并解析其对等依赖(peer dependency)。React 只在构建期声明时,安装器可以为这些额外依赖选择与浏览器产物不同的 React 版本。`use-sync-external-store@1.2.0` 与 `@tanstack/react-virtual@3.14.9` 都支持当前 React 18;问题不要求升级 React。
+
+## 决策
+
+浏览器专用第三方依赖属于 `devDependencies`,包括动态插件内联的实现、静态浏览器库的输入,以及由 Web 壳提供共享实例的 React。该规则部分取代[已发布依赖分类](2026-08-26-published-dependency-faces.zh.md)对普通第三方声明的保留策略;该文档继续规定包的选择范围、Host 值依赖与 Cordis 实例身份。
+
+依赖分类器从源码导入和 JSX 收集构建输入。Host 运行时可达的第三方库优先归入 `dependencies`;额外 Node 构建入口也必须纳入检查。源码中的纯类型引用不产生 Host 运行时依赖。配置元数据与共享 Host 导出的既有分类保持不变。
+
+NPM 字段不决定浏览器打包方式。动态插件继续内联私有库,并从平台模块表读取 React 等共享模块;静态浏览器库继续保留裸导入及样式,由最终 Vite 构建处理。静态包是 Web 壳的构建输入,不承诺独立安装后具备二次打包所需的全部依赖。源码构建需要开发依赖;安装已发布的 Web 产物不需要它们。
+
+许可证分类以交付内容为准。真实浏览器构建配置的依赖解析同时覆盖动态插件与 Web 壳;解析到的第三方实现仍计入[运行时披露](2026-07-30-generated-third-party-notices.zh.md),即使清单将其列为开发依赖。测试工具、纯类型导入和构建工具不会仅因处于 `devDependencies` 而算作分发代码。
+
+## 考虑过的替代方案
+
+**为生产安装额外声明 React,或强制覆盖对等依赖。** 这保留了浏览器不使用的额外安装图,且并未使安装副本成为浏览器共享实例。
+
+**把静态库的所有依赖提前内联。** 这会改变 Vite 对第三方分块、缓存和 CSS 的处理;依赖分类不需要改变这些构建行为。
+
+**把带 Client 的包的全部第三方依赖移走。** 双面包仍可能在 Host 加载库,例如 ZIP 输出所用的 `fflate` 和 RPC 校验所用的 `zod`;这些安装关系必须保留。
+
+**按清单字段直接划分许可证披露。** 浏览器代码的交付与安装器是否下载同名包不是同一事实,不能因此取消对分发代码的许可证检查。
+
+## 结果
+
+生产依赖不再为纯浏览器实现重复安装第三方库。React 与 React DOM 保持同一构建版本,插件继续消费 Web 壳的共享实例。分类测试同时约束浏览器 dev-only、Host 优先和修复幂等;发布检查拒绝 React 安装泄漏,浏览器产物验证独立覆盖实际模块加载。
+
+分类检查保留源码执行方式。许可证解析也不能依赖已有 `lib/` 或写入构建输出;其测试覆盖真实解析、纯类型擦除、资源引用与缺失依赖。独立消费静态包或公开类型的开发者需要自己提供相应构建依赖;本决策不增加独立浏览器库的支持承诺。

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-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-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

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md

@@ -0,0 +1,35 @@
+# Agent Note: Minimal profiles expose only a persistent shell
+
+Status: implemented
+
+English | [中文](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)
+
+## Problem
+
+The shipped Web `minimal` preset and standalone `sdk-minimal` profile exposed `str_replace_editor` beside their persistent shell. The editor added a second file-mutation interface and its complete schema to every minimal model request, although the shell already provides file inspection and mutation. It also required a dedicated `fs-local` service that no other row in either minimal composition consumed.
+
+Using one persistent shell gives the model a consistent file-operation interface and keeps the harness composition aligned with that interface. Leaving the editor mounted but hidden through a presentation filter would preserve an inactive capability that could reappear when presentation configuration changes.
+
+## Decision
+
+The shipped minimal compositions expose exactly one platform-selected persistent shell: `bash` on Linux and macOS, or `pwsh` on Windows. Neither composition mounts `@deepseek-ai/dsh-tool-str-replace-editor`, a filesystem tool, or the `fs-local` service that supported the editor. The fixed complete persona, absence of runtime context and compaction, shell timeout, and launch-specific host services remain unchanged.
+
+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.
+
+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.
+
+This decision partially supersedes the tool selection in [the bare minimal runtime](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) and the minimal exception in [the base editor decision](2026-09-05-base-default-file-editor.md). Those notes retain authority for prompt ownership, no-compaction behavior, and base-backed file editing. [The application architecture](../../../../docs/architecture.md) owns profile launch and bundle layering.
+
+## Alternatives considered
+
+**Keep the editor row and hide its schema.** Rejected because a presentation or restriction layer would leave the capability in the minimal composition and make its absence depend on another setting.
+
+**Remove the editor package from the distribution.** Rejected because explicit custom compositions remain valid consumers. The requirement concerns the two shipped minimal defaults.
+
+**Keep the editor only in `sdk-minimal`.** Rejected because the two minimal paths would present different tool contracts to the same model class, and the packaged SDK path would retain the schema cost and unused filesystem service.
+
+## Consequences
+
+Minimal agents inspect and modify files through their persistent shell. Their model requests carry one tool schema, and their compositions own no filesystem service. The editor package and explicit editor compositions remain available. Web and SDK replay fixtures pin the persistent Bash status trailers alongside shell state and file effects.

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 极简 profile 只提供持久 shell
+
+Status: implemented
+
+[English](2026-09-03-minimal-profiles-persistent-shell-only.md) | 中文
+
+## 问题
+
+随附 Web `minimal` preset 与独立 `sdk-minimal` profile 在持久 shell 之外还提供 `str_replace_editor`。Shell 已经可以检查和修改文件,editor 仍会为每个极简模型请求增加第二种文件修改接口及其完整 schema。它还要求挂载一个专用 `fs-local` 服务,而两份极简组合中的其他配置项都不使用该服务。
+
+只使用持久 shell 可以为模型提供一致的文件操作接口,并让 harness 组合与这一接口保持一致。如果保留 editor 的挂载,只通过呈现层过滤隐藏它,那么呈现配置变化时,该能力仍可能重新出现。
+
+## 决策
+
+随附的极简组合只提供一个按平台选择的持久 shell:Linux 与 macOS 使用 `bash`,Windows 使用 `pwsh`。两份组合都不挂载 `@deepseek-ai/dsh-tool-str-replace-editor`、文件系统工具或支撑 editor 的 `fs-local` 服务。固定的 complete persona、运行时上下文与 compaction 的缺失、shell 超时和各启动路径的宿主服务保持不变。
+
+独立 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 的描述与输出保持不变。
+
+精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。
+
+本决策部分取代[极简裸运行时](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)中的工具选择,以及[base 编辑器决策](2026-09-05-base-default-file-editor.zh.md)中的极简例外。这些 Agent Note 继续负责提示词所有权、无 compaction 行为和基于 base 的文件编辑。[应用架构](../../../../docs/architecture.zh.md)负责 profile 启动与 bundle 分层。
+
+## 考虑过的替代方案
+
+**保留 editor 配置项并隐藏其 schema。** 不予采用,因为呈现层或限制层会让该能力继续留在极简组合中,并使其缺失依赖另一项设置。
+
+**从发行物中删除 editor 包。** 不予采用,因为显式自定义组合仍是有效消费方。本需求只涉及两份随附的极简默认组合。
+
+**只在 `sdk-minimal` 中保留 editor。** 不予采用,因为两条极简路径会向同类模型提供不同的工具约定,而且打包 SDK 路径仍会承担 schema 成本和未被其他配置项使用的文件系统服务。
+
+## 后果
+
+极简 agent 通过持久 shell 检查和修改文件。模型请求只携带一个工具 schema,组合不拥有文件系统服务。editor 包和显式 editor 组合仍然可用。Web 与 SDK 回放快照会同时固定持久 Bash 的状态标记、shell 状态和文件操作结果。

+ 2 - 2
.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.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-05-base-default-file-editor.md
-2026-09-05-base-default-file-editor.md: 68a86769a99d697f9c7c767e9ba59b0cf61669b1
-2026-09-05-base-default-file-editor.zh.md: e8302b609a8647b1a0b90923361da06e859868ab
+2026-09-05-base-default-file-editor.md: b87d7986e7beeab30ff5414908239ba95022d231
+2026-09-05-base-default-file-editor.zh.md: db3c70d0a28527391073ddc34a72e9faabe1c3be

+ 3 - 3
.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md

@@ -12,7 +12,7 @@ The shared base selects both `read`/`write`/`edit` and `str_replace_editor`, whi
 
 The [base patch](../../../../packages/bundle/base/cordis.patch.yml) selects `read`, `write`, and `edit` for file editing. It does not insert `tool-str-replace-editor`; SDK and Web application patches therefore need no disabling override. The editor package remains available to compositions that insert it explicitly.
 
-[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) inserts its own `str-replace-editor` row in the agent scope. The standalone [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) inserts its own row without inheriting base. Both minimal compositions retain their editor.
+Web minimal and the standalone `sdk-minimal` bundle own their tool selection independently of base. The [persistent-shell-only decision](2026-09-03-minimal-profiles-persistent-shell-only.md) owns their single-tool defaults.
 
 This refines the shared tool defaults in [one dsh launcher](../architecture/2026-08-22-single-dsh-application-launcher.md). That note remains active for launch ownership, shared services, and patch precedence; no active note is fully superseded.
 
@@ -20,7 +20,7 @@ This refines the shared tool defaults in [one dsh launcher](../architecture/2026
 
 **Disable the editor separately in each application.** This leaves overlapping defaults in base and requires each consumer to opt out. The base owns the shared choice directly.
 
-**Delete the tool package or remove it from minimal.** The dedicated minimal compositions use this interface for file operations. Keeping the package and their explicit rows preserves that behavior.
+**Delete the tool package.** Explicit custom compositions still use this interface. Base default selection does not remove the package or constrain independently owned minimal defaults.
 
 ## Consequences
 
@@ -28,4 +28,4 @@ Base-backed SDK, headless, ACP, and custom profiles omit the editor schema by de
 
 ## Verification
 
-The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. [Web minimal snapshots](../../../../apps/web/tests/minimal-preset.snapshot.ts) exercise the editor through the minimal preset. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor.
+The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor.

+ 3 - 3
.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 [base patch](../../../../packages/bundle/base/cordis.patch.yml) 选择 `read`、`write` 和 `edit` 负责文件编辑。它不插入 `tool-str-replace-editor`;因此 SDK 与 Web 应用 patch 无需禁用覆盖。编辑器包仍可供显式插入它的组合使用。
 
-[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 在 agent 作用域插入自己的 `str-replace-editor` 配置项。独立的 [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) 不继承 base,自行插入配置项。两种极简组合都保留其编辑器
+Web minimal 与独立 `sdk-minimal` bundle 各自负责工具选择,不依赖 base。[仅持久 shell 决策](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)负责它们的单工具默认值
 
 本决策细化了[统一 dsh 启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中的共享工具默认值。该文档对启动所有权、共享服务和 patch 优先级仍然有效;没有被完全取代的活跃 Agent Note。
 
@@ -20,7 +20,7 @@ Status: implemented
 
 **在每个应用中分别禁用编辑器。** 这会在 base 中保留重叠的默认接口,并要求各消费方主动退出。共享选择由 base 直接负责。
 
-**删除工具包或从 minimal 移除它。** 专用的极简组合通过此接口完成文件操作。保留包及其显式配置项可以保留这一行为
+**删除工具包。** 显式自定义组合仍使用此接口。base 的默认选择不删除包,也不约束独立负责的极简默认值
 
 ## Consequences
 
@@ -28,4 +28,4 @@ Status: implemented
 
 ## Verification
 
-[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[Web minimal 快照](../../../../apps/web/tests/minimal-preset.snapshot.ts) 通过极简 preset 执行编辑器。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。
+[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。

+ 2 - 2
.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: d5447f93666a5cb27af2073a4a66f99d0c39f6ee
-2026-09-04-session-open-performance-gate.zh.md: 657f77fb76cf84afc36301d22d9a7798c559075b
+2026-09-04-session-open-performance-gate.md: d69e7424c5b34bc9f50d4a1d355279b65b7dfd8a
+2026-09-04-session-open-performance-gate.zh.md: cce4ae3921d70d43dde823d64dca5a0976344100

+ 2 - 0
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md

@@ -56,6 +56,8 @@ The pre-stack implementation keeps V0 as its current format, so first open does
 
 The [standard two-CPU run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961) at `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` uses Node 24.20.0 x64 and Ubuntu image `20260831.293.1`. Its five current-generation `open` samples are 49.2, 47.4, 49.1, 48.6, and 48.1 ms: median 48.6 ms, maximum 49.2 ms. The rounded 50 ms CI expectation gives a 63 ms limit without reapplying the 2× machine scale. The log identifies two available CPUs but not their model; it does not isolate hardware from the Node-version change. This is endpoint-specific runner calibration, not evidence of an application optimization or a new reference-machine measurement. Every other benchmark passes its existing budget. Deterministic controls reject the observed median at the historical 30 ms limit, accept it at 63 ms, reject a synthetic 75 ms reopen median, and reject a synthetic 4,000 ms first-open duration at its unchanged 550 ms limit. These controls verify budget enforcement, not a measured new regression.
 
+A cold-verifier packaging change removes runtime workspace-module loading without changing these budgets or the measured endpoint. On macOS arm64, Node 24.18.0, the same 127,400-event fixture at `ac48359b195558806ee5a2286697074fd1a52815` takes 164.2, 162.4, 159.7, 149.3, and 167.7 ms for first writable resume (median 162.4 ms). Bundling the verifier through the workspace build gives 119.8, 120.9, 121.9, 122.3, and 121.8 ms (median 121.8 ms, 25% lower). Retained heap stays at 5.4 MB; median peak RSS changes from 144.9 to 143.7 MB. Reopen medians are 27.5 and 27.1 ms, and all 16 Session cases, including the 128 MB completion checks, pass. A CPU profile attributes part of the old verifier cost to module resolution and compilation. The isolated-package built-worker test fails on the original worker because its workspace imports cannot resolve, and passes with the bundled worker, including rejection of an incorrect event count. These local results do not establish Linux runner timing; the existing 450 ms CI gate remains the acceptance check.
+
 The calibrated source budgets are:
 
 | Measurement | Reference expectation | CI budget |

+ 2 - 0
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md

@@ -56,6 +56,8 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
 
 `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` 上的[标准双 CPU 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961)使用 Node 24.20.0 x64 和 Ubuntu 镜像 `20260831.293.1`。当前 generation `open` 的五次样本为 49.2、47.4、49.1、48.6 和 48.1 ms:中位数 48.6 ms,最大值 49.2 ms。取整后的 50 ms CI 预期值给出 63 ms 上限,不重复乘以 2 倍机器系数。日志标明两个可用 CPU,但未记录型号;它无法区分硬件变化与 Node 版本变化的影响。这是端点专属的运行器校准,不是应用优化或参考机器新测量的证据。其他每项 benchmark 均通过既有预算。确定性正反例在历史 30 ms 上限下拒绝实测中位数,在 63 ms 下接受它,拒绝合成的 75 ms reopen 中位数,并以未改变的 550 ms 上限拒绝合成的 4,000 ms 首次打开耗时。这些正反例验证预算执行,不代表测得新的退化。
 
+一次冷 verifier 打包调整移除了运行时 workspace 模块加载,未改变这些预算或测量终点。在 macOS arm64、Node 24.18.0 上,`ac48359b195558806ee5a2286697074fd1a52815` 对同一份 127,400-event fixture 的首次 writable resume 耗时为 164.2、162.4、159.7、149.3、167.7 ms(中位数 162.4 ms)。通过 workspace build 打包 verifier 后为 119.8、120.9、121.9、122.3、121.8 ms(中位数 121.8 ms,降低 25%)。Retained heap 保持 5.4 MB;peak RSS 中位数从 144.9 变为 143.7 MB。Reopen 中位数为 27.5 和 27.1 ms,包含 128 MB completion check 的全部 16 项 Session 用例通过。CPU profile 将旧 verifier 的部分成本归因于模块解析和编译。隔离 package 的 built-worker 测试在旧 worker 上因无法解析 workspace import 而失败,在打包后的 worker 上通过,同时验证错误的 event count 会被拒绝。这些本地结果不能证明 Linux runner 耗时;现有 450 ms CI gate 仍是验收检查。
+
 校准后的源码预算如下:
 
 | 测量项 | 参考机预期 | CI 预算 |

+ 2 - 2
.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: f4316e790cf62f5027a0f7bfb2d3148cc79e7536
-2026-09-06-backend-continuation-performance.zh.md: 0fb36ba5375c61e907791b31d96f09062b7f62a3
+2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753
+2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09

+ 2 - 0
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md

@@ -23,6 +23,8 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a
 
 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. 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.
 
 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.

+ 2 - 0
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md

@@ -23,6 +23,8 @@ Status: implemented
 
 工具执行管线、请求准备、这些服务所需的 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、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
 
 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 8ea0a5892d78eda16e657334dba2af58b6648d09
-2026-09-08-ci-readiness-and-completion.zh.md: 62a4c64081369a20a576805fb8a465bffff2922d
+2026-09-08-ci-readiness-and-completion.md: 01d2d7f91a0ef10e161772d3c398261acc472238
+2026-09-08-ci-readiness-and-completion.zh.md: 584d8ea00c012e197cb75d9fbce03eb2e1fb03a1

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -10,6 +10,8 @@ The [empty master PR run](https://github.com/deepseek-harness/deepseek-harness/a
 
 Another [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437) reports a null publint child status and an LSP initialization-marker timeout. Their helpers impose five- and three-second limits inside the lane's 90-second test budget. These cases verify publication contents and cancellation behavior rather than cold-start latency.
 
+The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228) exhausts a one-second registry poll after transport failure. Disconnect cleanup includes cancellation, output draining, persistence, and owner disposal; registry removal alone does not establish complete teardown.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -18,6 +20,8 @@ The [desktop transaction test](../../../../apps/desktop/tests/project-manager.sp
 
 The [publint runner tests](../../../../scripts/publint-all.spec.ts) pass the active test budget to their child and check launch errors and termination signals before interpreting its exit code. The [LSP instance test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) uses the same budget for its fixture marker, observes the actual pending `didOpen` write before aborting, and captures the query's rejection before waiting for readiness. Its [server fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts) publishes the marker after pausing stdin. Teardown captures the instance list, Context, and directory before its first await.
 
+The [ACP disconnect tests](../../../../packages/acp/acp/tests/dispose.spec.ts) await the real session handle disposer for both EOF and transport failure. A barrier holds disposal pending while the test checks ownership, then releases it before awaiting completion and checking both registries. Neither case invokes plugin disposal to trigger the behavior under test. The independent teardown hook releases the barrier before disposing the captured Context, including when the test body times out.
+
 The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) owns lifecycle cleanup budgets. The [persistent PowerShell decision](2026-09-07-pwsh-ci-observable-completion.md) owns exact versus inferred terminal readiness; a one-shot process's completion promise has different semantics.
 
 ## Alternatives considered

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -10,6 +10,8 @@ Status: implemented
 
 另一次 [Windows coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437)报告了 publint 子进程退出状态为 null,以及 LSP 初始化标记等待超时。对应 helper 在通道的 90 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。
 
+[ACP coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228)在传输失败后耗尽一秒的注册表轮询期限。断连清理包含取消、输出排空、持久化和 owner 处置;仅从注册表移除不能证明完整拆卸已经结束。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -18,6 +20,8 @@ Status: implemented
 
 [publint runner 测试](../../../../scripts/publint-all.spec.ts)将当前测试预算传给子进程,并在解释退出码前检查启动错误和终止信号。[LSP 实例测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)用同一预算等待 fixture 标记,在取消前观察实际尚未完成的 `didOpen` 写入,并在等待就绪前接住查询的 rejection。[服务器 fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts)在暂停 stdin 后发布标记。Teardown 在首次 await 前捕获实例列表、Context 和目录。
 
+[ACP 断连测试](../../../../packages/acp/acp/tests/dispose.spec.ts)在 EOF 和传输失败两种情况下等待真实 Session handle 的 disposer。屏障阻塞处置,供测试检查所有权,然后释放屏障,等待完成并检查两个注册表。两个用例都不调用插件处置来触发待验证行为。独立的 teardown hook 在处置捕获的 Context 前释放屏障,包括测试体超时的情况。
+
 [子 Agent 拆卸决策](2026-09-07-subagent-teardown-test-budgets.zh.md)负责生命周期清理预算。[持久 PowerShell 决策](2026-09-07-pwsh-ci-observable-completion.zh.md)负责精确与推断的终端就绪状态;一次性进程的完成 Promise 具有不同语义。
 
 ## 考虑过的替代方案

+ 18 - 5
.github/review-ownership/README.md

@@ -1,12 +1,13 @@
-# Automated review requests
+# Automated pull-request reviews
 
 ## Summary
 
-The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
+The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch.
 
 ## Table of Contents
 
 - [Routing](#routing)
+- [Approval scoring](#approval-scoring)
 - [Review exclusions](#review-exclusions)
 - [Security](#security)
 - [Verification](#verification)
@@ -28,6 +29,18 @@ The ownership map accepts explicit absolute directory patterns and one or two in
 
 The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
 
+<a id="approval-scoring"></a>
+
+## Approval scoring
+
+The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails.
+
+Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count.
+
+Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
+
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
+
 <a id="review-exclusions"></a>
 
 ## Review exclusions
@@ -44,15 +57,15 @@ For a modified file with a supported source extension, the scanner compares the
 
 ## Security
 
-The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs.
+The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs.
 
-Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run.
+Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run.
 
 <a id="verification"></a>
 
 ## Verification
 
-Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
 
 <a id="dev-note"></a>
 

+ 12 - 0
.github/review-ownership/approval-policy.json

@@ -0,0 +1,12 @@
+{
+  "requiredPoints": 2,
+  "defaultPoints": 1,
+  "reviewerPoints": {
+    "07akioni": 2,
+    "imccyu": 2,
+    "tianyicui": 2,
+    "tianyicui-bot": 2,
+    "turtle1999": 2,
+    "turtle2099": 2
+  }
+}

+ 377 - 0
.github/review-ownership/check-approval.mjs

@@ -0,0 +1,377 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import process from 'node:process'
+import { pathToFileURL } from 'node:url'
+
+const API_VERSION = '2026-03-10'
+const MAX_PULL_REQUEST_REVIEWS = 3_000
+const PAGE_SIZE = 100
+const STATUS_CONTEXT = 'weighted approval'
+const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human'
+const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
+const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
+const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
+
+class GitHubApiError extends Error {
+  constructor(message, status) {
+    super(message)
+    this.name = 'GitHubApiError'
+    this.status = status
+  }
+}
+
+/**
+ * Parse the approval score policy.
+ * @param {string} source Approval policy JSON.
+ * @returns {{requiredPoints: number, defaultPoints: number, reviewerPoints: Map<string, number>}} Validated policy.
+ */
+export function parseApprovalPolicy(source) {
+  const value = JSON.parse(source)
+  if (!isRecord(value)) throw new Error('approval policy must be an object')
+  const fields = Object.keys(value).sort()
+  if (fields.join(',') !== 'defaultPoints,requiredPoints,reviewerPoints') {
+    throw new Error('approval policy must contain only defaultPoints, requiredPoints, and reviewerPoints')
+  }
+  const requiredPoints = positiveInteger(value.requiredPoints, 'requiredPoints')
+  const defaultPoints = positiveInteger(value.defaultPoints, 'defaultPoints')
+  if (!isRecord(value.reviewerPoints)) throw new Error('reviewerPoints must be an object')
+  const reviewerPoints = new Map()
+  for (const [login, pointsValue] of Object.entries(value.reviewerPoints)) {
+    validateLogin(login, 'approval policy reviewer')
+    const key = login.toLowerCase()
+    if (reviewerPoints.has(key)) throw new Error(`duplicate approval policy reviewer @${login}`)
+    reviewerPoints.set(key, positiveInteger(pointsValue, `reviewerPoints.${login}`))
+  }
+  return { requiredPoints, defaultPoints, reviewerPoints }
+}
+
+/**
+ * Select each reviewer's current approval or change-request decision.
+ * @param {unknown[]} reviews Pull-request review records in GitHub's chronological order.
+ * @returns {Array<{login: string, state: 'APPROVED' | 'CHANGES_REQUESTED'}>} Effective review decisions.
+ */
+export function effectiveReviewDecisions(reviews) {
+  const decisions = new Map()
+  for (const review of reviews) {
+    if (!isRecord(review)) throw new Error('pull-request review is not an object')
+    if (review.user === null) continue
+    if (!isRecord(review.user) || typeof review.user.login !== 'string') {
+      throw new Error('pull-request review has no reviewer login')
+    }
+    const login = validateLogin(review.user.login, 'pull-request reviewer')
+    if (typeof review.state !== 'string' || !REVIEW_STATES.has(review.state.toUpperCase())) {
+      throw new Error(`pull-request review by @${login} has an invalid state`)
+    }
+    const state = review.state.toUpperCase()
+    const key = login.toLowerCase()
+    if (state === 'DISMISSED') {
+      decisions.delete(key)
+    } else if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') {
+      decisions.set(key, { login, state })
+    }
+  }
+  return [...decisions.values()]
+}
+
+/**
+ * Create a repository-scoped GitHub JSON API caller.
+ * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
+ * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
+ */
+export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
+  if (!token) throw new Error('GITHUB_TOKEN is not set')
+  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
+  const root = apiUrl.replace(/\/+$/u, '')
+  return async (path, { method = 'GET', body } = {}) => {
+    const response = await fetchImpl(`${root}${path}`, {
+      method,
+      headers: {
+        Accept: 'application/vnd.github+json',
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+        'User-Agent': 'deepseek-harness-weighted-approval',
+        'X-GitHub-Api-Version': API_VERSION,
+      },
+      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+    })
+    if (!response.ok) {
+      const responseBody = await response.text()
+      throw new GitHubApiError(
+        `GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`,
+        response.status,
+      )
+    }
+    if (response.status === 204) return undefined
+    return response.json()
+  }
+}
+
+/**
+ * Fetch every pull-request review or fail before scoring a partial list.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise<unknown[]>} Complete review list within the supported limit.
+ */
+export async function listPullRequestReviews(api, repository, pullNumber) {
+  const reviews = []
+  for (let page = 1; ; page++) {
+    const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
+    reviews.push(...response)
+    if (response.length < PAGE_SIZE) return reviews
+    if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
+      throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} records`)
+    }
+  }
+}
+
+/**
+ * Evaluate approval points from current reviews and repository permissions.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Runtime inputs.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'failure' | 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
+ */
+export async function evaluateApproval({ event, policySource, api }) {
+  const pull = pullRequestFromEvent(event)
+  const policy = parseApprovalPolicy(policySource)
+  if (pull.draft) {
+    return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request')
+  }
+
+  const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
+  const decisions = effectiveReviewDecisions(reviews)
+    .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
+  const permissions = []
+  for (const { login, state } of decisions) {
+    permissions.push({ login, state, permission: await reviewerPermission(api, pull.repository, login) })
+  }
+  const approvals = []
+  const blockers = []
+  const ignoredReviewers = []
+  for (const { login, state, permission } of permissions) {
+    if (!WRITABLE_PERMISSIONS.has(permission)) {
+      ignoredReviewers.push(login)
+    } else if (state === 'CHANGES_REQUESTED') {
+      blockers.push(login)
+    } else {
+      approvals.push({
+        login,
+        points: policy.reviewerPoints.get(login.toLowerCase()) ?? policy.defaultPoints,
+      })
+    }
+  }
+  approvals.sort((left, right) => left.login.localeCompare(right.login, 'en'))
+  blockers.sort((left, right) => left.localeCompare(right, 'en'))
+  ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en'))
+  const points = approvals.reduce((total, approval) => {
+    const next = total + approval.points
+    if (!Number.isSafeInteger(next)) throw new Error('approval points exceed the safe integer range')
+    return next
+  }, 0)
+  if (blockers.length > 0) {
+    return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'failure',
+      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`)
+  }
+  const state = points >= policy.requiredPoints ? 'success' : 'pending'
+  return approvalResult(
+    pull,
+    policy.requiredPoints,
+    approvals,
+    blockers,
+    ignoredReviewers,
+    state,
+    `${points}/${policy.requiredPoints} approval points`,
+  )
+}
+
+/**
+ * Evaluate and publish the required commit status, publishing an error status when evaluation fails.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void}} options Runtime inputs.
+ * @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
+ */
+export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
+  const pull = pullRequestFromEvent(event)
+  write(STATUS_PREFIX)
+  let result
+  try {
+    result = await evaluateApproval({ event, policySource, api })
+  } catch (error) {
+    await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl)
+    throw error
+  }
+  write(`Approval score: ${result.points}/${result.requiredPoints}.`)
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points }) => `@${login}: ${points}`))
+  writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
+  writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
+  await publishStatus(api, pull, result.state, result.description, runUrl)
+  write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
+  return result
+}
+
+/**
+ * Resolve the reviewed pull request from a completed review-event workflow run.
+ * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
+ * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
+ */
+export async function approvalEventFromWorkflowRun({ event, api }) {
+  const repository = repositoryFromEvent(event)
+  if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event'
+    || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') {
+    throw new Error('event has no successful weighted approval review workflow run')
+  }
+  const expectedHeadSha = validateHeadSha(event.workflow_run.head_sha, 'workflow run')
+  const pullNumber = parsePullNumber(event.workflow_run.display_title)
+  if (!Array.isArray(event.workflow_run.pull_requests)) {
+    throw new Error('workflow run has no pull_requests array')
+  }
+  if (event.workflow_run.pull_requests.length > 0
+    && !event.workflow_run.pull_requests.some(pull => isRecord(pull) && pull.number === pullNumber)) {
+    throw new Error(`workflow run is not associated with pull request #${pullNumber}`)
+  }
+  const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
+  if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
+  if (pull.head.sha !== expectedHeadSha) return null
+  return { ...event, pull_request: pull }
+}
+
+function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) {
+  return {
+    pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
+    state,
+    description: `${STATUS_PREFIX}: ${detail}.`,
+    points: approvals.reduce((total, approval) => total + approval.points, 0),
+    requiredPoints,
+    approvals,
+    blockers,
+    ignoredReviewers,
+  }
+}
+
+async function reviewerPermission(api, repository, login) {
+  let response
+  try {
+    response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`)
+  } catch (error) {
+    if (error instanceof GitHubApiError && error.status === 404) return 'none'
+    throw error
+  }
+  if (!isRecord(response) || typeof response.permission !== 'string') {
+    throw new Error(`collaborator permission response for @${login} has no permission`)
+  }
+  return response.permission.toLowerCase()
+}
+
+async function publishStatus(api, pull, state, description, runUrl) {
+  if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
+  if (description.length > 140) throw new Error('commit status description exceeds 140 characters')
+  await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, {
+    method: 'POST',
+    body: {
+      state,
+      context: STATUS_CONTEXT,
+      description,
+      target_url: runUrl,
+    },
+  })
+}
+
+function pullRequestFromEvent(event) {
+  const repository = repositoryFromEvent(event)
+  if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)
+    || !isRecord(event.pull_request.head)) {
+    throw new Error('event has no pull_request')
+  }
+  const pull = event.pull_request
+  if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
+  if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
+  const author = validateLogin(pull.user.login, 'pull-request author')
+  const headSha = validateHeadSha(pull.head.sha, 'pull request')
+  return {
+    repository,
+    number: pull.number,
+    draft: pull.draft,
+    author,
+    headSha,
+  }
+}
+
+function repositoryFromEvent(event) {
+  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string'
+    || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(event.repository.full_name)) {
+    throw new Error('event has no valid repository.full_name')
+  }
+  return event.repository.full_name
+}
+
+function validateHeadSha(value, subject) {
+  if (typeof value !== 'string' || !/^[0-9a-f]{40}$/u.test(value)) {
+    throw new Error(`${subject} has no valid head SHA`)
+  }
+  return value
+}
+
+function parsePullNumber(source) {
+  if (typeof source !== 'string') throw new Error('review event has no valid run title')
+  const match = /^weighted-approval-review-event:([1-9][0-9]*)$/u.exec(source)
+  if (!match) throw new Error('review event has no valid run title')
+  const pullNumber = Number(match[1])
+  if (!Number.isSafeInteger(pullNumber)) throw new Error('review event pull request number is not a safe integer')
+  return pullNumber
+}
+
+function positiveInteger(value, field) {
+  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${field} must be a positive integer`)
+  return value
+}
+
+function validateLogin(value, subject) {
+  if (typeof value !== 'string' || !LOGIN.test(value)) throw new Error(`${subject} has an invalid login`)
+  return value
+}
+
+function writeList(write, title, entries) {
+  write(`${title}:`)
+  if (entries.length === 0) write('- (none)')
+  else for (const entry of entries) write(`- ${entry}`)
+}
+
+function isRecord(value) {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+async function main() {
+  const eventPath = process.env.GITHUB_EVENT_PATH
+  if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
+  let event = JSON.parse(readFileSync(eventPath, 'utf8'))
+  const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
+  const api = createGitHubApi({
+    token: process.env.GITHUB_TOKEN ?? '',
+    apiUrl: process.env.GITHUB_API_URL,
+  })
+  if (isRecord(event) && isRecord(event.workflow_run)) {
+    const resolved = await approvalEventFromWorkflowRun({
+      event,
+      api,
+    })
+    if (resolved === null) {
+      process.stdout.write(`${STATUS_PREFIX}\n`)
+      process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
+      return
+    }
+    event = resolved
+  }
+  await runApprovalCheck({
+    event,
+    policySource,
+    api,
+    runUrl: process.env.GITHUB_RUN_URL ?? '',
+  })
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  main().catch((error) => {
+    process.stderr.write(`weighted approval failed: ${error instanceof Error ? error.message : String(error)}\n`)
+    process.exitCode = 1
+  })
+}

+ 317 - 0
.github/review-ownership/check-approval.test.mjs

@@ -0,0 +1,317 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+import {
+  approvalEventFromWorkflowRun,
+  createGitHubApi,
+  effectiveReviewDecisions,
+  evaluateApproval,
+  listPullRequestReviews,
+  parseApprovalPolicy,
+  runApprovalCheck,
+} from './check-approval.mjs'
+
+const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
+const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
+
+const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
+  repository: { full_name: 'deepseek-harness/deepseek-harness' },
+  pull_request: {
+    number: 42,
+    draft,
+    user: { login: author },
+    head: { sha: HEAD_SHA },
+  },
+})
+
+const review = (login, state) => ({ user: { login }, state })
+
+test('loads the repository approval score policy', () => {
+  const policy = parseApprovalPolicy(policySource)
+  assert.equal(policy.requiredPoints, 2)
+  assert.equal(policy.defaultPoints, 1)
+  assert.deepEqual([...policy.reviewerPoints], [
+    ['07akioni', 2],
+    ['imccyu', 2],
+    ['tianyicui', 2],
+    ['tianyicui-bot', 2],
+    ['turtle1999', 2],
+    ['turtle2099', 2],
+  ])
+})
+
+test('rejects invalid approval score policies', () => {
+  for (const [source, message] of [
+    ['[]', /must be an object/u],
+    ['{"requiredPoints":0,"defaultPoints":1,"reviewerPoints":{}}', /requiredPoints/u],
+    ['{"requiredPoints":2,"defaultPoints":0,"reviewerPoints":{}}', /defaultPoints/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":[]}', /reviewerPoints must be an object/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{},"typo":2}', /contain only/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"bad login":2}}', /invalid login/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"User":2,"user":2}}', /duplicate/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"user":-1}}', /positive integer/u],
+  ]) {
+    assert.throws(() => parseApprovalPolicy(source), message)
+  }
+})
+
+test('uses each reviewer current decision and clears it on dismissal', () => {
+  assert.deepEqual(effectiveReviewDecisions([
+    review('first', 'APPROVED'),
+    review('first', 'APPROVED'),
+    review('first', 'COMMENTED'),
+    review('first', 'DISMISSED'),
+    review('second', 'CHANGES_REQUESTED'),
+    review('second', 'APPROVED'),
+    review('third', 'APPROVED'),
+    review('third', 'CHANGES_REQUESTED'),
+    review('dismissed', 'DISMISSED'),
+    { user: null, state: 'APPROVED' },
+  ]), [
+    { login: 'second', state: 'APPROVED' },
+    { login: 'third', state: 'CHANGES_REQUESTED' },
+  ])
+})
+
+test('resolves a review workflow run to the current pull request and rejects stale heads', async () => {
+  const workflowRunEvent = {
+    repository: { full_name: 'deepseek-harness/deepseek-harness' },
+    workflow_run: {
+      name: 'weighted-approval-review-event',
+      event: 'pull_request_review',
+      conclusion: 'success',
+      head_sha: HEAD_SHA,
+      display_title: 'weighted-approval-review-event:42',
+      pull_requests: [],
+    },
+  }
+  const current = await approvalEventFromWorkflowRun({
+    event: workflowRunEvent,
+    api: async path => {
+      assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42')
+      return pullRequestEvent().pull_request
+    },
+  })
+  assert.equal(current.pull_request.number, 42)
+
+  assert.equal(await approvalEventFromWorkflowRun({
+    event: workflowRunEvent,
+    api: async () => ({
+      ...pullRequestEvent().pull_request,
+      head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' },
+    }),
+  }), null)
+  await assert.rejects(approvalEventFromWorkflowRun({
+    event: {
+      ...workflowRunEvent,
+      workflow_run: { ...workflowRunEvent.workflow_run, display_title: '../42' },
+    },
+    api: async () => { throw new Error('invalid number must not call GitHub') },
+  }), /valid run title/u)
+})
+
+test('fetches every pull-request review and rejects an unbounded history', async () => {
+  let calls = 0
+  const reviews = await listPullRequestReviews(async () => {
+    calls++
+    return calls === 1 ? Array.from({ length: 100 }, () => review('user', 'COMMENTED')) : []
+  }, 'owner/repo', 42)
+  assert.equal(reviews.length, 100)
+  assert.equal(calls, 2)
+
+  calls = 0
+  await assert.rejects(listPullRequestReviews(async () => {
+    calls++
+    return Array.from({ length: 100 }, () => review('user', 'COMMENTED'))
+  }, 'owner/repo', 42), /exceed 3000/u)
+  assert.equal(calls, 30)
+})
+
+test('accepts one two-point approval from a write-capable reviewer', async () => {
+  const calls = []
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      calls.push(path)
+      if (path.includes('/reviews?')) return [review('07akioni', 'APPROVED')]
+      if (path.includes('/collaborators/07akioni/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'success')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.approvals, [{ login: '07akioni', points: 2 }])
+  assert.equal(calls.length, 2)
+})
+
+test('accepts two one-point approvals and ignores reviews without write access', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) {
+        return [
+          review('reader', 'APPROVED'),
+          review('writer-b', 'APPROVED'),
+          review('writer-a', 'APPROVED'),
+        ]
+      }
+      if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
+      if (path.includes('/collaborators/writer-a/permission')) return { permission: 'admin' }
+      if (path.includes('/collaborators/writer-b/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'success')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.approvals, [
+    { login: 'writer-a', points: 1 },
+    { login: 'writer-b', points: 1 },
+  ])
+  assert.deepEqual(result.ignoredReviewers, ['reader'])
+})
+
+test('keeps one one-point approval pending without failing the status', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'pending')
+  assert.equal(result.points, 1)
+})
+
+test('ignores a reviewer whose collaborator permission lookup returns 404', async () => {
+  const api = createGitHubApi({
+    token: 'secret',
+    fetchImpl: async (url) => {
+      if (url.includes('/reviews?')) {
+        return new Response(JSON.stringify([review('former-writer', 'APPROVED')]), {
+          status: 200,
+          headers: { 'Content-Type': 'application/json' },
+        })
+      }
+      if (url.includes('/collaborators/former-writer/permission')) return new Response('Not Found', { status: 404 })
+      throw new Error(`unexpected API URL ${url}`)
+    },
+  })
+  const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
+  assert.equal(result.state, 'pending')
+  assert.deepEqual(result.ignoredReviewers, ['former-writer'])
+})
+
+test('blocks on a write-capable change request but ignores the author and read-only blockers', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent({ author: 'author' }),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) {
+        return [
+          review('turtle1999', 'APPROVED'),
+          review('blocker', 'CHANGES_REQUESTED'),
+          review('reader', 'CHANGES_REQUESTED'),
+          review('author', 'CHANGES_REQUESTED'),
+        ]
+      }
+      if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' }
+      if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' }
+      if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'failure')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.blockers, ['blocker'])
+  assert.deepEqual(result.ignoredReviewers, ['reader'])
+})
+
+test('keeps drafts pending without reading reviews', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent({ draft: true }),
+    policySource,
+    api: async () => { throw new Error('draft evaluation must not call GitHub') },
+  })
+  assert.equal(result.state, 'pending')
+  assert.equal(result.points, 0)
+  assert.match(result.description, /draft pull request/u)
+})
+
+test('publishes the required status and replaces stale success with error on evaluation failure', async () => {
+  const calls = []
+  const output = []
+  const result = await runApprovalCheck({
+    event: pullRequestEvent(),
+    policySource,
+    runUrl: 'https://github.example/actions/runs/1',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
+      if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
+      if (path.includes('/statuses/')) return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => output.push(line),
+  })
+  assert.equal(result.state, 'success')
+  assert.deepEqual(calls.at(-1), {
+    path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
+    options: {
+      method: 'POST',
+      body: {
+        state: 'success',
+        context: 'weighted approval',
+        description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.',
+        target_url: 'https://github.example/actions/runs/1',
+      },
+    },
+  })
+  assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human')
+
+  const failures = []
+  await assert.rejects(runApprovalCheck({
+    event: pullRequestEvent(),
+    policySource,
+    runUrl: 'https://github.example/actions/runs/2',
+    api: async (path, options = {}) => {
+      if (path.includes('/reviews?')) throw new Error('reviews unavailable')
+      if (path.includes('/statuses/')) {
+        failures.push({ path, options })
+        return {}
+      }
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  }), /reviews unavailable/u)
+  assert.equal(failures[0].options.body.state, 'error')
+})
+
+test('sends authenticated JSON and escapes an API error body', async () => {
+  const requests = []
+  const api = createGitHubApi({
+    token: 'secret',
+    apiUrl: 'https://github.example/api/v3/',
+    fetchImpl: async (url, options) => {
+      requests.push({ url, options })
+      return new Response(JSON.stringify({ ok: true }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      })
+    },
+  })
+  assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
+  assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
+  assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
+  assert.equal(requests[0].options.body, '{"value":1}')
+
+  const failing = createGitHubApi({
+    token: 'secret',
+    fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
+  })
+  await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
+})

+ 40 - 51
.github/workflows/node-addon-system.yml

@@ -1,7 +1,8 @@
 # CI for the node-addon-system packages under native/system. A separate
 # workflow from ci.yml keeps the native OS/architecture matrix independent of
-# the harness Node matrix. Release assembly and publication use the companion
-# Node Addon System Release workflow.
+# the harness Node matrix. Each platform job builds once and tests those bytes
+# under every supported Node release. Release assembly and publication use the
+# companion Node Addon System Release workflow.
 name: Node Addon System
 
 on:
@@ -46,14 +47,11 @@ jobs:
     runs-on: ubuntu-24.04
     outputs:
       ci: ${{ steps.matrix.outputs.ci }}
-      compatibility: ${{ steps.matrix.outputs.compatibility }}
     steps:
       - uses: actions/checkout@v4
 
       - id: matrix
-        run: |
-          echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
-          echo "compatibility=$(node ./scripts/github-matrix.mjs compatibility)" >> "$GITHUB_OUTPUT"
+        run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
 
   native:
     name: ${{ matrix.platform }}
@@ -112,68 +110,59 @@ jobs:
         env:
           NALR_REQUIRE_LANDLOCK: ${{ runner.os == 'Linux' && '1' || '0' }}
 
-      - name: Verify platform payload rules
+      - name: Verify platform payload rules (Node 24)
         run: pnpm test:packaging
 
-      - name: Flock behavior (built addon)
+      - name: Flock behavior (Node 24, built addon)
         run: |
           pnpm build:test-oracle
           pnpm test:flock
 
-      - name: Upload this platform's built addon and entry
-        uses: actions/upload-artifact@v4
-        with:
-          name: system-compat-${{ matrix.platform }}
-          path: |
-            native/system/packages/*/bin/**
-            native/system/packages/entry/lib/**
-          if-no-files-found: error
-
-      - name: Upload independent syscall test oracle
-        uses: actions/upload-artifact@v4
-        with:
-          name: system-oracle-${{ matrix.platform }}
-          path: native/system/test/bin/**
-          if-no-files-found: error
-
-  compatibility:
-    name: ${{ matrix.platform }} / Node ${{ matrix.node }} (same binary)
-    needs: [matrix, native]
-    strategy:
-      fail-fast: false
-      matrix:
-        include: ${{ fromJson(needs.matrix.outputs.compatibility) }}
-    runs-on: ${{ matrix.runner }}
-    steps:
-      - uses: actions/checkout@v4
+      - name: Test the same musl addon on Node 24 without a compiler
+        if: runner.os == 'Linux'
+        run: >-
+          docker run --rm -v "$PWD:$PWD" -w "$PWD"
+          node:24-alpine
+          node --test ./test/flock.test.js ./test/package-matrix.test.js
 
       - uses: actions/setup-node@v4
         with:
-          node-version: ${{ matrix.node }}
+          node-version: 20
 
-      - name: Download the original platform build
-        uses: actions/download-artifact@v4
-        with:
-          name: system-compat-${{ matrix.platform }}
-          path: native/system/packages
+      - name: Test the same binaries on Node 20
+        run: node --test ./test/flock.test.js ./test/package-matrix.test.js
 
-      - name: Download independent syscall test oracle
-        uses: actions/download-artifact@v4
+      - name: Test the same musl addon on Node 20 without a compiler
+        if: runner.os == 'Linux'
+        run: >-
+          docker run --rm -v "$PWD:$PWD" -w "$PWD"
+          node:20-alpine
+          node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+      - uses: actions/setup-node@v4
         with:
-          name: system-oracle-${{ matrix.platform }}
-          path: native/system/test/bin
+          node-version: 22
 
-      - name: Restore oracle executable permissions
-        run: find ./test/bin -type f -name flock-oracle -exec chmod +x {} +
+      - name: Test the same binaries on Node 22
+        run: node --test ./test/flock.test.js ./test/package-matrix.test.js
 
-      - name: Test without rebuilding or installing dependencies
-        run: |
-          node ./test/link-platform.mjs
+      - name: Test the same musl addon on Node 22 without a compiler
+        if: runner.os == 'Linux'
+        run: >-
+          docker run --rm -v "$PWD:$PWD" -w "$PWD"
+          node:22-alpine
           node --test ./test/flock.test.js ./test/package-matrix.test.js
 
-      - name: Test the same musl addon without a compiler
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 26
+
+      - name: Test the same binaries on Node 26
+        run: node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+      - name: Test the same musl addon on Node 26 without a compiler
         if: runner.os == 'Linux'
         run: >-
           docker run --rm -v "$PWD:$PWD" -w "$PWD"
-          node:${{ matrix.node }}-alpine
+          node:26-alpine
           node --test ./test/flock.test.js ./test/package-matrix.test.js

+ 19 - 0
.github/workflows/weighted-approval-review-event.yml

@@ -0,0 +1,19 @@
+name: weighted-approval-review-event
+run-name: weighted-approval-review-event:${{ github.event.pull_request.number }}
+
+on:
+  pull_request_review:
+    types: [submitted, edited, dismissed]
+
+permissions: {}
+
+jobs:
+  record-review-event:
+    name: record weighted approval review event
+    runs-on: ubuntu-latest
+    timeout-minutes: 2
+    steps:
+      - name: Record review event
+        run: |
+          echo 'This is by automated Angry Turtle Cyborg, not a human'
+          echo 'Recorded a weighted approval review event.'

+ 37 - 0
.github/workflows/weighted-approval.yml

@@ -0,0 +1,37 @@
+name: weighted-approval
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
+  workflow_run:
+    workflows: [weighted-approval-review-event]
+    types: [completed]
+
+permissions:
+  contents: read
+  pull-requests: read
+  statuses: write
+
+concurrency:
+  group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}
+  cancel-in-progress: false
+
+jobs:
+  publish-status:
+    if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
+    name: publish weighted approval status
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      # SECURITY: the status-writing job executes policy from the trusted default
+      # branch and reads pull-request reviews only as API data.
+      - name: Check out trusted approval policy
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          persist-credentials: false
+      - name: Publish weighted approval status
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: node .github/review-ownership/check-approval.mjs

+ 5 - 4
THIRD_PARTY_NOTICES.md

@@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this
 
 ## Runtime npm dependencies
 
-External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
+External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
 
 | Package | License |
 | --- | --- |
@@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
-| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
@@ -88,12 +87,12 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
 | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
 | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
-| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
 | [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
 | [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
 | [`open`](https://github.com/sindresorhus/open) | MIT |
+| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 |
 | [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
 | [`react`](https://github.com/facebook/react) | MIT |
 | [`react-dom`](https://github.com/facebook/react) | MIT |
@@ -138,7 +137,7 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac
 
 ## Development-only npm dependencies
 
-External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
+External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure.
 
 | Package | License |
 | --- | --- |
@@ -155,6 +154,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -190,6 +190,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
 | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
+| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |

+ 1 - 0
apps/cli/package.json

@@ -76,6 +76,7 @@
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
     "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: 42fb2855e97465417ca284944102d3c6a610416d
-README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e
+README.md: 99ef8310cd1316e9ee4ade55fcac00766ae1e821
+README.zh.md: aa446d539a5b666e7745f459b5a014b22e72f90e

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

@@ -90,11 +90,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
 
 Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
 
-The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
+The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
 
 New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
 
-`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
+`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
 
 ## Shared deployment behavior
 

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

@@ -92,11 +92,11 @@ dsh web --help
 
 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
 
-基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。
+基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。
 
 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
 
-`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
+`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
 
 ## 共享部署行为
 

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

@@ -1081,7 +1081,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         ['pty', '@deepseek-ai/dsh-terminal'],
         ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
         ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
-        ['fs-local', '@deepseek-ai/dsh-fs-local'],
         ['timer', '@deepseek-ai/cordis-plugin-timer'],
         ['llm', '@deepseek-ai/dsh-llm'],
         ['session', '@deepseek-ai/dsh-session'],
@@ -1099,7 +1098,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         ['agent-loop', '@deepseek-ai/dsh-agent-loop'],
         ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
         ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
-        ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'],
         ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
       ])
       expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')

+ 1 - 0
apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl

@@ -22,6 +22,7 @@
 {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"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":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"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":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
+{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}}
 {"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"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}}

+ 13 - 12
apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl

@@ -12,16 +12,17 @@
 {"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":"tool/result","seq":17,"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":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"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":20,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"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":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":25,"time":0,"data":{"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}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"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":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"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":"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}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"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":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
+{"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":"user/message","seq":26,"time":0,"data":{"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}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"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":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}}

+ 55 - 7
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -189,8 +189,25 @@ describe('Python SDK dsh profile keyless smoke', () => {
     }
   }, 40_000)
 
-  it('boots the standalone minimal profile through its generated manifest', async () => {
+  it.each([
+    { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false },
+    { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true },
+  ])('$label', async ({ editorEnabled }) => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
+    const editorPatch = join(root, 'editor.patch.yml')
+    if (editorEnabled) {
+      const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8')
+      const yaml = guide.split('<a id="opt-in-to-str_replace_editor"></a>')[1]
+        ?.match(/```yaml\n([\s\S]*?)```/)?.[1]
+      expect(yaml).toBeDefined()
+      await writeFile(editorPatch, yaml!)
+    }
+    const editorFile = join(root, 'editor.txt')
+    const editorContent = 'sdk-minimal editor opt-in\n'
+    const editorCalls = editorEnabled ? [
+      { command: 'create', path: editorFile, file_text: editorContent },
+      { command: 'view', path: editorFile },
+    ] : []
     const modelRequests: Record<string, unknown>[] = []
     const modelServer = createServer((request, response) => {
       let body = ''
@@ -200,8 +217,21 @@ describe('Python SDK dsh profile keyless smoke', () => {
         modelRequests.push(JSON.parse(body) as Record<string, unknown>)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
         response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
-        response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
-        response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
+        const toolCall = editorCalls[modelRequests.length - 1]
+        if (toolCall) {
+          response.write(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{
+            index: 0,
+            id: `editor-${toolCall.command}`,
+            type: 'function',
+            function: { name: 'str_replace_editor', arguments: JSON.stringify(toolCall) },
+          }] } }] })}\n\n`)
+        } else {
+          response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
+        }
+        response.write(`data: ${JSON.stringify({
+          choices: [{ delta: {}, finish_reason: toolCall ? 'tool_calls' : 'stop' }],
+          usage: { prompt_tokens: 3, completion_tokens: 1 },
+        })}\n\n`)
         response.end('data: [DONE]\n\n')
       })
     })
@@ -214,6 +244,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
       binScript,
       '--profile',
       'sdk-minimal',
+      ...(editorEnabled ? ['--patch', editorPatch] : []),
     ], {
       cwd: repoRoot,
       env: {
@@ -251,11 +282,14 @@ describe('Python SDK dsh profile keyless smoke', () => {
         method: 'session/prompt',
         params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
       })}\n`)
-      await waitForLine(lines, (value) => {
+      const turnEnd = await waitForLine(lines, (value) => {
         const params = value.params as Record<string, unknown> | undefined
         const event = params?.event as Record<string, unknown> | undefined
         return params?.sessionId === 'minimal' && event?.type === 'turn/end'
       }, () => stderr)
+      expect(turnEnd).toMatchObject({
+        params: { event: { data: { reason: { kind: 'completed' } } } },
+      })
 
       const profile = JSON.parse(
         await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
@@ -266,13 +300,27 @@ describe('Python SDK dsh profile keyless smoke', () => {
       })
       expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
       const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
-      expect(tools.map(tool => tool.function?.name).sort()).toEqual(
-        [process.platform === 'win32' ? 'pwsh' : 'bash', 'str_replace_editor'].sort(),
-      )
+      expect(tools.map(tool => tool.function?.name)).toEqual([
+        process.platform === 'win32' ? 'pwsh' : 'bash',
+        ...(editorEnabled ? ['str_replace_editor'] : []),
+      ])
+      expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1)
+      if (editorEnabled) {
+        expect(await readFile(editorFile, 'utf8')).toBe(editorContent)
+        expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([
+          expect.objectContaining({
+            role: 'tool',
+            tool_call_id: 'editor-view',
+            content: expect.stringContaining(editorContent.trim()) as unknown,
+          }),
+        ]))
+      }
 
       child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
       await waitForLine(lines, value => value.id === 3, () => stderr)
       const exit = await child
+      expect(exit.timedOut, stderr).toBe(false)
+      expect(exit.signal, stderr).toBeUndefined()
       expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
     } finally {
       child.kill('SIGKILL')

+ 18 - 17
apps/cli/tests/web-agent-presets.e2e.ts

@@ -32,8 +32,7 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
 const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
 * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
-* You don't have access to the internet via this tool.
-* You do have access to a mirror of common linux and python packages via apt and pip.
+* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.
 * State is persistent across command calls and discussions with the user.
 * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
 * Please avoid commands that may produce a very large amount of output.
@@ -63,6 +62,8 @@ async function bootWeb(
     // back on the next run, so a stored document from any other build decides
     // this test's boot. Same reason the settings row above is pinned.
     { id: 'storage-json', config: { root: storageRoot } },
+    // Fixed Session IDs must stay inside this boot's temporary profile root.
+    { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } },
     // Host rows with side effects outside this process: a bound port, a served
     // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
     // stay ENABLED on purpose — the api-proxy is the host row that injects
@@ -189,9 +190,7 @@ describe('the shipped Web composition', () => {
   it('leaves the global tool layer empty', () => {
     // Every model-facing tool belongs to a preset, `ask_user_question`
     // included: a tool in the global layer reaches EVERY agent regardless of
-    // which preset composed it, so a two-tool benchmark surface would really
-    // present three. A regression here means an agent-plane row came back to
-    // the host composition.
+    // which preset composed it, expanding that preset's tool list.
     expect(toolNames(ctx)).toEqual([])
   })
 
@@ -243,7 +242,7 @@ describe('the shipped Web composition', () => {
       // depend on ripgrep being present on the machine.
       expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
         'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
-        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
+        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'ralph', 'read', 'read_image', 'send_message', 'skill',
         'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
         'workflow', 'write',
       ])
@@ -287,7 +286,7 @@ describe('the shipped Web composition', () => {
     }
   })
 
-  it('composes the exact RL prompt and two tools from `minimal`', async () => {
+  it('composes the exact RL prompt and persistent shell from `minimal`', async () => {
     const handle = await ctx.agents.create({
       sessionId: SessionId('preset-minimal'),
       setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
@@ -297,11 +296,13 @@ describe('the shipped Web composition', () => {
       expect(assembly.sections).toEqual([
         { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT },
       ])
-      expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
+      expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
       expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
-      expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
-        .toContain('Absolute path')
       expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
+      // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs.
+      expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined()
+      expect(ctx.get('fs')?.sandboxMode).toBeDefined()
+      expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode)
       expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined()
       expect(handle.agent.ctx.get('compaction')).toBeUndefined()
     } finally {
@@ -319,7 +320,7 @@ describe('the shipped Web composition', () => {
       setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
     })
     try {
-      expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
+      expect(toolNames(ctx, minimal.agent)).toEqual(['bash'])
       expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
 
       await minimal.dispose()
@@ -470,7 +471,7 @@ describe('the shipped Web composition', () => {
       // stays the preset's choice — minimal mounts no `tool-skill`, so its
       // tool table has no loader even though the global layer is readable.
       expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
-      expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+      expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
     } finally {
       await handle.dispose()
     }
@@ -849,7 +850,7 @@ describe('authoring a preset on the shipped composition', () => {
     try {
       // The same tools the shipped `minimal` composes, from a directory copied
       // through the service into a root outside the installed harness.
-      expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+      expect(toolNames(authorCtx, handle.agent)).toEqual(['bash'])
     } finally {
       await handle.dispose()
     }
@@ -884,9 +885,9 @@ describe('the default preset as a user setting', () => {
         setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
       })
       try {
-        // `mount()` with no id resolves the effective default. Two tools, not
+        // `mount()` with no id resolves the effective default. One tool, not
         // `standard`'s catalog: the setting decided the composition.
-        expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+        expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
       } finally {
         await handle.dispose()
       }
@@ -911,7 +912,7 @@ describe('a session keeps the preset it was created with', () => {
     try {
       // The api-proxy guard reads exactly this: the header records what the
       // session runs, so naming anything else is a caller error rather than a
-      // switch. Its history was produced under `minimal`'s two tools.
+      // switch. Its history was produced under `minimal`'s single tool.
       expect(handle.agent.session.header.agentPreset).toBe('minimal')
     } finally {
       await handle.dispose()
@@ -973,7 +974,7 @@ describe('a composition that configures its own preset roots', () => {
       setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined),
     })
     try {
-      expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+      expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash'])
     } finally {
       await handle.dispose()
     }

+ 1 - 1
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -150,7 +150,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
     const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
     expect(metadata).toContain('name: 我的模式')
-    expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
+    expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。')
     expect(metadata).not.toContain('order:')
   }, 60_000)
 

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác